mirror of
https://github.com/block/buzz.git
synced 2026-11-08 02:11:36 +00:00
Refine task conversations
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -41,6 +41,7 @@ function ForumPostRouteComponent() {
|
||||
<ChannelRouteScreen
|
||||
channelId={channelId}
|
||||
selectedPostId={postId}
|
||||
targetAgentConversationReplyId={null}
|
||||
targetMessageId={null}
|
||||
targetReplyId={search.replyId ?? null}
|
||||
targetThreadRootId={null}
|
||||
|
||||
@@ -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}
|
||||
|
||||
24
desktop/src/features/agents/agentConversationLink.ts
Normal file
24
desktop/src/features/agents/agentConversationLink.ts
Normal 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()}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user