Refine task conversations

This commit is contained in:
klopez4212
2026-06-26 18:54:58 +01:00
parent f88e5f5ce0
commit 85655c0192
18 changed files with 1029 additions and 235 deletions

View File

@@ -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.

View File

@@ -33,10 +33,35 @@ fn parse_message_deep_link(url: &Url) -> Option<serde_json::Value> {
}))
}
/// 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<serde_json::Value> {
let mut channel: Option<String> = None;
let mut reply: Option<String> = 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=<ws(s)://...>` — emits `deep-link-connect` to the frontend
/// - `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]` — emits `deep-link-message`
/// - `buzz://task?channel=<uuid>&reply=<eventId>` — 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());
}
}

View File

@@ -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],

View File

@@ -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}

View File

@@ -41,6 +41,7 @@ function ForumPostRouteComponent() {
<ChannelRouteScreen
channelId={channelId}
selectedPostId={postId}
targetAgentConversationReplyId={null}
targetMessageId={null}
targetReplyId={search.replyId ?? null}
targetThreadRootId={null}

View File

@@ -15,6 +15,7 @@ type ChannelRouteSearch = {
profile?: string;
profileTab?: ProfilePanelTab;
profileView?: ProfilePanelView;
taskReplyId?: string;
thread?: string;
threadRootId?: string;
};
@@ -32,6 +33,7 @@ function validateChannelSearch(
profile: nonEmptyString(search.profile),
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
profileView: parseProfilePanelView(search.profileView) ?? undefined,
taskReplyId: nonEmptyString(search.taskReplyId),
thread: nonEmptyString(search.thread),
threadRootId: nonEmptyString(search.threadRootId),
};
@@ -58,6 +60,7 @@ function ChannelRouteComponent() {
<ChannelRouteScreen
channelId={channelId}
selectedPostId={null}
targetAgentConversationReplyId={search.taskReplyId ?? null}
targetMessageId={search.messageId ?? null}
targetReplyId={null}
targetThreadRootId={search.threadRootId ?? search.thread ?? null}

View File

@@ -0,0 +1,24 @@
const AGENT_CONVERSATION_LINK_SCHEME = "buzz:";
const AGENT_CONVERSATION_LINK_HOST = "task";
export type AgentConversationLinkInput = {
agentReplyId: string;
channelId: 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()}`;
}

View File

@@ -1,7 +1,8 @@
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 { buildAgentConversationLink } from "@/features/agents/agentConversationLink";
import {
buildAgentConversationMentionPubkeys,
buildAgentConversationMarkers,
@@ -58,6 +59,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";
@@ -707,9 +709,7 @@ export function AgentConversationScreen({
>
<Summary className="h-4 w-4" />
<span>
{isPublishingThreadSummary
? "Generating recap..."
: "Send recap to thread"}
{isPublishingThreadSummary ? "Generating recap..." : "Send recap"}
</span>
</Button>
</TooltipTrigger>
@@ -718,25 +718,53 @@ export function AgentConversationScreen({
</TooltipContent>
</Tooltip>
);
const headerLeadingContent = onBackToThread ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Back to source thread"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
data-testid="agent-conversation-back-to-thread"
onClick={() => onBackToThread(conversation)}
title="Back to source thread"
type="button"
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Back to source thread</TooltipContent>
</Tooltip>
) : (
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 = (
<>
<ChevronRight
aria-hidden
className="h-4 w-4 translate-y-px shrink-0 text-muted-foreground/70"
/>
<span className="flex h-6 min-w-0 items-center truncate text-base font-normal leading-6 tracking-tight text-foreground/55">
<AnimatedTextSwap
className="max-w-full overflow-hidden text-ellipsis"
key={`${conversation.id}:${conversation.titleStatus}`}
value={conversation.title}
/>
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Copy task link"
className="h-6 w-6 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/title:opacity-100"
data-testid="agent-conversation-copy-link"
onClick={() => void handleCopyConversationLink()}
size="icon-xs"
title="Copy task link"
type="button"
variant="ghost"
>
<Copy className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy task link</TooltipContent>
</Tooltip>
</>
);
return (
@@ -748,16 +776,22 @@ export function AgentConversationScreen({
>
<ChatHeader
actions={headerActions}
animatedTitle
animatedTitleResetKey={conversation.id}
belowSystemChrome
chromeWrapperRef={headerChromeRef}
channelType="dm"
compactTitleStack
leadingContent={headerLeadingContent}
leadingContentContainerClassName="-ml-2 mr-0.5"
leadingContentLayout="side"
title={conversation.title}
channelType={sourceChannelType}
showCopyTitle={false}
title={sourceChannelName}
titleAction={
onBackToThread
? {
ariaLabel: `Back to ${sourceChannelName}`,
onClick: () => onBackToThread(conversation),
title: "Back to source thread",
}
: undefined
}
titleTrailingContent={headerTitleTrailingContent}
visibility={sourceChannelVisibility}
/>
<MessageTimeline

View File

@@ -1,5 +1,13 @@
import * as React from "react";
import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react";
import {
Bot,
ClipboardPlus,
Hash,
LogIn,
Plus,
Sparkles,
UserPlus,
} from "lucide-react";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
@@ -11,7 +19,10 @@ import {
MessageTimeline,
type MessageTimelineHandle,
} from "@/features/messages/ui/MessageTimeline";
import { getHiddenAgentConversationMessageIds } from "@/features/agents/agentConversations";
import {
getHiddenAgentConversationMessageIds,
type AgentConversationMarker,
} from "@/features/agents/agentConversations";
import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay";
import {
getDmHuddleMemberPubkeys,
@@ -49,14 +60,248 @@ 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 {
formatDayHeading,
formatTime,
} from "@/features/messages/lib/dateFormatters";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import type { TimelineMessage } from "@/features/messages/types";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { isWelcomeChannel } from "@/features/onboarding/welcome";
import type { Channel } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { useAppShell } from "@/app/AppShellContext";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
type ChannelTaskItem = {
marker: AgentConversationMarker;
message: TimelineMessage;
threadMessage: TimelineMessage;
};
function buildTaskFallbackMessage(
marker: AgentConversationMarker,
): TimelineMessage {
const createdAt = marker.startedAt || marker.createdAt;
return {
author: marker.agentName,
avatarUrl: null,
body: "",
createdAt,
depth: 0,
id: marker.agentReplyId,
parentId: marker.parentMessageId,
pubkey: marker.agentPubkey,
rootId: marker.threadRootId,
time: formatTime(createdAt),
};
}
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;
onOpenAgentConversation?: (
message: TimelineMessage,
options?: { publishMarker?: boolean },
) => 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 (
<article
className="group/task mx-1 min-w-0 overflow-hidden rounded-lg border border-border/70 bg-muted/35 transition-colors hover:bg-muted/45 focus-within:bg-muted/45"
data-agent-conversation-id={marker.eventId}
data-testid="channel-task-row"
>
<div className="flex min-w-0 items-center gap-3 px-3 py-2">
<div
aria-hidden
className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-background p-2.5 text-muted-foreground shadow-xs ring-1 ring-border/60"
>
<ClipboardPlus className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p
className="truncate text-sm font-medium text-foreground"
title={marker.title}
>
{marker.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{starterName} · {formatTaskStartedAt(startedAt)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 opacity-0 transition-opacity group-hover/task:opacity-100 group-focus-within/task:opacity-100">
<Button
className="h-8 rounded-lg px-3 text-xs font-medium"
data-testid="channel-task-go-to-thread"
disabled={!onGoToTaskMessage}
onClick={() => onGoToTaskMessage?.(marker, message, threadMessage)}
title="Go to source message in channel"
type="button"
variant="secondary"
>
Go to message
</Button>
<Button
className="h-8 rounded-lg px-3 text-xs font-medium"
data-testid="channel-task-open"
disabled={!onOpenAgentConversation}
onClick={() =>
onOpenAgentConversation?.(message, { publishMarker: false })
}
type="button"
variant="outline"
>
Open
</Button>
</div>
</div>
</article>
);
}
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<HTMLDivElement | null>;
}) {
const messageById = React.useMemo(
() => new Map(messages.map((message) => [message.id, message])),
[messages],
);
const taskItems = React.useMemo<ChannelTaskItem[]>(() => {
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 (
<div
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
data-testid="channel-tasks-view"
>
<div
className="absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-none px-2 pb-8 pt-1 [overflow-anchor:none]"
ref={scrollContainerRef}
>
<div
className={cn(
"mx-auto flex w-full max-w-4xl flex-col gap-6 px-3",
channelChrome.contentPadding,
)}
>
{taskItems.length === 0 ? (
<div
className="mt-10 rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
data-testid="channel-tasks-empty"
>
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground">
<ClipboardPlus className="size-5" />
</div>
<p className="mt-4 text-base font-semibold tracking-tight">
No tasks yet
</p>
<p className="mx-auto mt-2 max-w-sm text-sm leading-5 text-muted-foreground">
New tasks will appear here when an agent conversation is opened
from this channel.
</p>
</div>
) : (
<div className="mt-3 flex min-w-0 flex-col gap-2">
{taskItems.map(({ marker, message, threadMessage }) => (
<ChannelTaskRow
currentPubkey={currentPubkey}
key={marker.eventId}
marker={marker}
message={message}
onOpenAgentConversation={onOpenAgentConversation}
onGoToTaskMessage={onGoToTaskMessage}
profiles={profiles}
threadMessage={threadMessage}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}
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<MessageTimelineHandle>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
const { openAgentConversation } = useAppShell();
const [taskFocusMessageId, setTaskFocusMessageId] = React.useState<
string | null
>(null);
const previousTaskFocusChannelIdRef = React.useRef<string | null>(null);
const completedWelcomeBannerChannelIdsRef = React.useRef(new Set<string>());
const welcomeComposerDismissTimerRef = React.useRef<number | null>(null);
const welcomeComposerHideTimerRef = React.useRef<number | null>(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 ? (
<div className={cn("absolute inset-x-0 z-40", channelChrome.top)}>
<ChannelFindBar
matchCount={channelFind.matchCount}
@@ -700,155 +988,179 @@ export const ChannelPane = React.memo(function ChannelPane({
/>
</div>
) : null}
<MessageTimeline
ref={messageTimelineRef}
agentConversationMarkers={agentConversationMarkers}
agentPubkeys={agentPubkeys}
channelId={activeChannel?.id}
channelIntro={channelIntro}
directMessageIntro={directMessageIntro}
scrollContainerRef={timelineScrollRef}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
followThreadById={followThreadById}
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFetchingOlder={isFetchingOlder}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
personaLookup={personaLookup}
profiles={profiles}
unfollowThreadById={unfollowThreadById}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
mainEntries={mainTimelineEntries}
messages={visibleMessages}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onOpenAgentConversation={handleOpenAgentConversation}
onReply={activeChannel?.archivedAt ? undefined : onOpenThread}
channelName={activeChannel?.name}
channelType={activeChannel?.channelType ?? null}
isSendingVideoReviewComment={isSending}
onSendVideoReviewComment={
activeChannel?.archivedAt ? undefined : onSendVideoReviewComment
}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
searchActiveMessageId={channelFind.activeMatch?.messageId ?? null}
searchMatchingMessageIds={channelFind.matchingMessageIds}
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
threadUnreadCounts={threadUnreadCounts}
/>
{isNonMemberView ? (
<div
data-testid="join-banner"
className="flex items-center gap-3 border-t border-border/80 bg-card/50 px-5 py-3"
>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-muted-foreground">
<Hash className="h-4 w-4 shrink-0" />
<span className="truncate">
Viewing{" "}
<span className="font-medium text-foreground">
#{activeChannel?.name}
</span>
</span>
</div>
<Button
disabled={isJoining}
onClick={() => {
void onJoinChannel?.();
}}
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-4 w-4" />
{isJoining ? "Joining..." : "Join to participate"}
</Button>
</div>
{isTasksSurface ? (
<ChannelTasksView
activeChannel={activeChannel}
agentConversationMarkers={agentConversationMarkers}
currentPubkey={currentPubkey}
messages={messages}
onOpenAgentConversation={handleOpenAgentConversation}
onGoToTaskMessage={handleGoToTaskMessage}
profiles={profiles}
scrollContainerRef={timelineScrollRef}
/>
) : (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
ref={composerWrapperRef}
>
<div className="pointer-events-auto">
{isActiveWelcomeChannel ? (
<WelcomeComposerBanner state={welcomeComposerBannerState} />
) : null}
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
channelType={activeChannel?.channelType ?? null}
containerClassName="px-5"
disabled={isComposerDisabled}
editTarget={mainEditTarget}
isSending={isSending}
mediaController={mainComposerMedia}
onCancelEdit={onCancelEdit}
onEditLastOwnMessage={handleEditLastOwnMainMessage}
onEditSave={onEditSave}
onSend={handleSendMessage}
profiles={profiles}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? activeChannel.channelType === "dm" &&
directMessageIntro
? `Message ${directMessageIntro.displayName}`
: `Message #${activeChannel.name}`
: "Select a channel"
}
showTopBorder={false}
/>
<div className="h-7 overflow-visible bg-background px-5 pb-1 pt-0">
<div className="flex h-full w-full items-center gap-2 overflow-visible">
{hasComposerBotActivity ? (
<div className="shrink-0 overflow-visible">
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={handleOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={composerBotTypingPubkeys}
variant="inline"
/>
</div>
) : null}
{hasTypingActivity ? (
<TypingIndicatorRow
channel={activeChannel}
className="min-w-0 flex-1 py-0 pl-[calc(0.75rem+1px)] pr-0 sm:pl-[calc(1rem+1px)]"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
<>
<MessageTimeline
ref={messageTimelineRef}
agentConversationMarkers={agentConversationMarkers}
agentPubkeys={agentPubkeys}
channelId={activeChannel?.id}
channelIntro={channelIntro}
directMessageIntro={directMessageIntro}
scrollContainerRef={timelineScrollRef}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
followThreadById={followThreadById}
hasComposerOverlay={hasMainComposerOverlay}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
layoutShiftKey={
useSplitAuxiliaryPane
? (openThreadHeadId ?? "closed")
: "overlay"
}
isFollowingThreadById={isFollowingThreadById}
isMessageUnreadById={isMessageUnreadById}
personaLookup={personaLookup}
profiles={profiles}
unfollowThreadById={unfollowThreadById}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
mainEntries={mainTimelineEntries}
messages={visibleMessages}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
onDelete={onDelete}
onEdit={onEdit}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onOpenAgentConversation={handleOpenAgentConversation}
onReply={activeChannel?.archivedAt ? undefined : onOpenThread}
channelName={activeChannel?.name}
channelType={activeChannel?.channelType ?? null}
isSendingVideoReviewComment={isSending}
onSendVideoReviewComment={
activeChannel?.archivedAt
? undefined
: onSendVideoReviewComment
}
onTargetReached={handleTimelineTargetReached}
onToggleReaction={onToggleReaction}
searchActiveMessageId={
channelFind.activeMatch?.messageId ?? null
}
searchMatchingMessageIds={channelFind.matchingMessageIds}
searchQuery={channelFind.query}
targetMessageId={taskFocusMessageId ?? targetMessageId}
threadUnreadCounts={threadUnreadCounts}
/>
{isNonMemberView ? (
<div
data-testid="join-banner"
className="flex items-center gap-3 border-t border-border/80 bg-card/50 px-5 py-3"
>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-muted-foreground">
<Hash className="h-4 w-4 shrink-0" />
<span className="truncate">
Viewing{" "}
<span className="font-medium text-foreground">
#{activeChannel?.name}
</span>
</span>
</div>
<Button
disabled={isJoining}
onClick={() => {
void onJoinChannel?.();
}}
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-4 w-4" />
{isJoining ? "Joining..." : "Join to participate"}
</Button>
</div>
) : (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
ref={composerWrapperRef}
>
<div className="pointer-events-auto">
{isActiveWelcomeChannel ? (
<WelcomeComposerBanner
state={welcomeComposerBannerState}
/>
) : null}
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
channelType={activeChannel?.channelType ?? null}
containerClassName="px-5"
disabled={isComposerDisabled}
editTarget={mainEditTarget}
isSending={isSending}
mediaController={mainComposerMedia}
onCancelEdit={onCancelEdit}
onEditLastOwnMessage={handleEditLastOwnMainMessage}
onEditSave={onEditSave}
onSend={handleSendMessage}
profiles={profiles}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? activeChannel.channelType === "dm" &&
directMessageIntro
? `Message ${directMessageIntro.displayName}`
: `Message #${activeChannel.name}`
: "Select a channel"
}
showTopBorder={false}
/>
<div className="h-7 overflow-visible bg-background px-5 pb-1 pt-0">
<div className="flex h-full w-full items-center gap-2 overflow-visible">
{hasComposerBotActivity ? (
<div className="shrink-0 overflow-visible">
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={handleOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={composerBotTypingPubkeys}
variant="inline"
/>
</div>
) : null}
{hasTypingActivity ? (
<TypingIndicatorRow
channel={activeChannel}
className="min-w-0 flex-1 py-0 pl-[calc(0.75rem+1px)] pr-0 sm:pl-[calc(1rem+1px)]"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
) : null}
</div>
</div>
</div>
</div>
</div>
</div>
)}
</>
)}
{canDropInMainColumn && mainComposerMedia.isDragOver ? (
<DropZoneOverlay className="z-30 rounded-none" />
@@ -856,7 +1168,7 @@ export const ChannelPane = React.memo(function ChannelPane({
</section>
) : null}
{channelManagementOpen && activeChannel ? (
{!isTasksSurface && channelManagementOpen && activeChannel ? (
<ChannelManagementAuxiliaryPanel
activeChannel={activeChannel}
canResetThreadPanelWidth={canResetThreadPanelWidth}
@@ -870,7 +1182,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
transparentChrome={hasSplitAuxiliaryPane}
/>
) : threadHeadMessage ? (
) : !isTasksSurface && threadHeadMessage ? (
(() => {
const panel = (
<MessageThreadPanel
@@ -925,7 +1237,7 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapAux(panel, "message-thread-panel");
})()
) : shouldShowThreadSkeleton ? (
) : !isTasksSurface && shouldShowThreadSkeleton ? (
(() => {
const panel = (
<MessageThreadPanelSkeleton
@@ -940,7 +1252,7 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapAux(panel, "message-thread-panel");
})()
) : activeChannel && selectedAgent ? (
) : !isTasksSurface && activeChannel && selectedAgent ? (
(() => {
const panel = (
<AgentSessionThreadPanel
@@ -972,7 +1284,7 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapAux(panel, "agent-session-thread-panel");
})()
) : profilePanelPubkey ? (
) : !isTasksSurface && profilePanelPubkey ? (
(() => {
const panel = (
<UserProfilePanel

View File

@@ -63,6 +63,7 @@ export type ChannelPaneProps = {
onOpenMembers?: () => 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 },

View File

@@ -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<ChannelSurfaceTab>("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<string | null>(
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}

View File

@@ -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;

View File

@@ -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<void>;
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<HTMLDivElement>(null);
const tabTriggerRefs = React.useRef<
Record<ChannelSurfaceTab, HTMLButtonElement | null>
>({
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 ? (
<Tabs
className="shrink-0"
onValueChange={(value) =>
onSurfaceTabChange?.(value as ChannelSurfaceTab)
}
value={activeSurfaceTab}
>
<TabsList className={CHANNEL_SURFACE_TAB_LIST_CLASS} ref={tabListRef}>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 z-20 h-[3px] origin-left rounded-[3px] bg-foreground opacity-0 transition-[transform,width,opacity] duration-[180ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none data-[ready=true]:opacity-100"
data-ready={tabIndicator.width > 0}
data-testid="channel-surface-tab-indicator"
style={{
transform: `translate3d(${tabIndicator.left}px, 0, 0)`,
width: tabIndicator.width,
}}
/>
<TabsTrigger
className={CHANNEL_SURFACE_TAB_TRIGGER_CLASS}
ref={(element) => {
tabTriggerRefs.current.messages = element;
}}
value="messages"
>
Messages
</TabsTrigger>
<TabsTrigger
className={CHANNEL_SURFACE_TAB_TRIGGER_CLASS}
ref={(element) => {
tabTriggerRefs.current.tasks = element;
}}
value="tasks"
>
Tasks
</TabsTrigger>
</TabsList>
</Tabs>
) : undefined;
return (
<ChatHeader
belowTitleContent={surfaceTabs}
belowTitleContentClassName="mt-5"
belowSystemChrome
chromeWrapperRef={chromeWrapperRef}
actions={actions}

View File

@@ -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 <FileText className={HEADER_ICON_CLASS} />;
}
return <Hash className={CHANNEL_HASH_ICON_CLASS} color="gray" />;
return <Hash className={CHANNEL_HASH_ICON_CLASS} />;
}
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 = (
<header
className={cn(
"pointer-events-auto relative z-30 min-w-0 shrink-0 cursor-default select-none bg-transparent px-5 py-2 transition-[margin,padding] duration-200 ease-linear",
"pointer-events-auto relative z-30 min-w-0 shrink-0 cursor-default select-none bg-transparent px-5 transition-[margin,padding] duration-200 ease-linear",
belowTitleContent ? "pb-0 pt-2" : "py-2",
overlaysContent && !belowSystemChrome && "-mb-14",
clearCollapsedTopChromeControls && "pl-[176px]",
)}
data-testid="chat-header"
data-tauri-drag-region
>
{belowTitleContent ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-border/70"
/>
) : null}
<div
className={cn(
"flex min-w-0 items-center gap-2.5",
compactTitleStack ? "min-h-10" : "h-9",
"flex min-w-0 gap-2.5",
belowTitleContent ? "items-start" : "items-center",
belowTitleContent
? compactTitleStack
? "min-h-10"
: "min-h-9"
: compactTitleStack
? "min-h-10"
: "h-9",
)}
>
{renderedLeadingContent && leadingContentLayout === "side" ? (
@@ -162,7 +190,12 @@ export function ChatHeader({
</div>
) : null}
<div className="min-w-0 flex-1">
<div className="group/title flex min-w-0 items-center gap-[4px] overflow-hidden">
<div
className={cn(
"group/title flex min-w-0 items-center gap-[4px] overflow-hidden",
belowTitleContent && (compactTitleStack ? "min-h-10" : "h-9"),
)}
>
{renderedLeadingContent && leadingContentLayout === "inline" ? (
<div className={cn("shrink-0", leadingContentContainerClassName)}>
{renderedLeadingContent}
@@ -172,11 +205,26 @@ export function ChatHeader({
className={cn(
"min-w-0 truncate text-base font-semibold tracking-tight",
compactTitleStack ? "leading-5" : "translate-y-px leading-6",
titleTrailingContent &&
"flex max-w-[45%] shrink-0 items-center",
)}
data-testid="chat-title"
title={trimmedDescription || undefined}
>
{animatedTitle ? (
{titleAction ? (
<button
aria-label={titleAction.ariaLabel}
className={cn(
"flex max-w-full items-center truncate rounded-sm text-left outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
compactTitleStack ? "h-5 leading-5" : "h-6 leading-6",
)}
onClick={titleAction.onClick}
title={titleAction.title}
type="button"
>
{title}
</button>
) : animatedTitle ? (
<AnimatedTextSwap
className="max-w-full overflow-hidden text-ellipsis"
key={animatedTitleResetKey}
@@ -186,20 +234,27 @@ export function ChatHeader({
title
)}
</h1>
<Button
aria-label={`Copy channel name: ${title}`}
className={cn(
"shrink-0 opacity-0 text-muted-foreground transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/title:opacity-100",
compactTitleStack ? "h-5 w-5 [&_svg]:size-3" : "h-6 w-6",
)}
onClick={() => void handleCopyTitle()}
size="icon-xs"
title="Copy channel name"
type="button"
variant="ghost"
>
<Copy className="h-3.5 w-3.5" />
</Button>
{titleTrailingContent ? (
<div className="flex h-6 min-w-0 flex-1 items-center gap-[4px] overflow-hidden">
{titleTrailingContent}
</div>
) : null}
{showCopyTitle ? (
<Button
aria-label={`Copy channel name: ${title}`}
className={cn(
"shrink-0 opacity-0 text-muted-foreground transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/title:opacity-100",
compactTitleStack ? "h-5 w-5 [&_svg]:size-3" : "h-6 w-6",
)}
onClick={() => void handleCopyTitle()}
size="icon-xs"
title="Copy channel name"
type="button"
variant="ghost"
>
<Copy className="h-3.5 w-3.5" />
</Button>
) : null}
{statusBadge ? (
<div className="flex shrink-0 flex-wrap items-center gap-1">
{statusBadge}
@@ -216,9 +271,19 @@ export function ChatHeader({
{trimmedSubtitle}
</p>
) : null}
{belowTitleContent ? (
<div className={cn("mt-1.5 min-w-0", belowTitleContentClassName)}>
{belowTitleContent}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<div
className={cn(
"flex shrink-0 items-center gap-1",
belowTitleContent && (compactTitleStack ? "min-h-10" : "h-9"),
)}
>
<UpdateIndicator />
{actions ? <div className="shrink-0">{actions}</div> : null}
</div>

View File

@@ -1,4 +1,4 @@
import { MessagesSquare } from "lucide-react";
import { ClipboardPlus } from "lucide-react";
import type { AgentConversationMarker } from "@/features/agents/agentConversations";
import type { TimelineMessage } from "@/features/messages/types";
@@ -303,11 +303,11 @@ export function AgentConversationMarkerRow({
aria-hidden
className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-background p-2.5 text-muted-foreground shadow-xs ring-1 ring-border/60"
>
<MessagesSquare className="size-5" />
<ClipboardPlus className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
Dedicated conversation
New task
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{marker.title}

View File

@@ -1,6 +1,7 @@
import {
BellOff,
BellRing,
ClipboardPlus,
Clock,
Copy,
CornerUpLeft,
@@ -8,7 +9,6 @@ import {
Link2,
MailCheck,
MailOpen,
MessagesSquare,
Pencil,
SmilePlus,
Trash2,
@@ -528,7 +528,7 @@ export function MessageActionBar({
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Continue conversation"
aria-label="New task"
className={ACTION_BUTTON_CLASS}
data-testid={`continue-conversation-${message.id}`}
onClick={() => {
@@ -538,10 +538,10 @@ export function MessageActionBar({
type="button"
variant="ghost"
>
<MessagesSquare className={ACTION_ICON_CLASS} />
<ClipboardPlus className={ACTION_ICON_CLASS} />
</Button>
</TooltipTrigger>
<TooltipContent>Continue conversation</TooltipContent>
<TooltipContent>New task</TooltipContent>
</Tooltip>
) : null}

View File

@@ -23,6 +23,15 @@ export type MessageDeepLinkPayload = {
threadRootId: string | null;
};
/**
* Payload emitted by the Rust deep-link handler for `buzz://task?…`.
* `agentReplyId` is the shared event id used to reconstruct/open the task.
*/
export type AgentConversationDeepLinkPayload = {
agentReplyId: string;
channelId: string;
};
/**
* Register listeners for deep-link events emitted by the Rust backend.
*
@@ -64,3 +73,14 @@ export function listenForMessageDeepLinks(
onOpen(event.payload);
});
}
export function listenForAgentConversationDeepLinks(
onOpen: (payload: AgentConversationDeepLinkPayload) => void,
): Promise<UnlistenFn> {
return listen<AgentConversationDeepLinkPayload>(
"deep-link-agent-conversation",
(event) => {
onOpen(event.payload);
},
);
}

View File

@@ -1,7 +1,10 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { listenForMessageDeepLinks } from "@/shared/deep-link";
import {
listenForAgentConversationDeepLinks,
listenForMessageDeepLinks,
} from "@/shared/deep-link";
/**
* Subscribe to `buzz://message` deep links emitted by the Tauri backend
@@ -22,16 +25,25 @@ export function useMessageDeepLinks() {
React.useEffect(() => {
let cancelled = false;
const unlistenPromise = listenForMessageDeepLinks((payload) => {
const messageUnlistenPromise = listenForMessageDeepLinks((payload) => {
if (cancelled) return;
void goChannel(payload.channelId, {
messageId: payload.messageId,
threadRootId: payload.threadRootId,
});
});
const agentConversationUnlistenPromise =
listenForAgentConversationDeepLinks((payload) => {
if (cancelled) return;
void goChannel(payload.channelId, {
taskReplyId: payload.agentReplyId,
});
});
return () => {
cancelled = true;
void unlistenPromise.then((fn) => fn());
void messageUnlistenPromise.then((fn) => fn());
void agentConversationUnlistenPromise.then((fn) => fn());
};
}, [goChannel]);
}