Address task list review feedback

This commit is contained in:
klopez4212
2026-06-30 13:55:18 +01:00
parent 86e6db1e05
commit cd636b305e
3 changed files with 307 additions and 235 deletions
+12 -235
View File
@@ -1,13 +1,5 @@
import * as React from "react";
import {
Bot,
ClipboardPlus,
Hash,
LogIn,
Plus,
Sparkles,
UserPlus,
} from "lucide-react";
import { Bot, 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";
@@ -40,6 +32,7 @@ import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThre
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import { ChannelTasksView } from "@/features/channels/ui/ChannelTasksView";
import {
containsWelcomePersonaMention,
WelcomeComposerBanner,
@@ -60,236 +53,15 @@ 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 { isBroadcastReply } from "@/features/messages/lib/threading";
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 | null;
threadMessage: TimelineMessage | null;
};
function formatTaskStartedAt(unixSeconds: number): string {
return `${formatDayHeading(unixSeconds)} at ${formatTime(unixSeconds)}`;
}
function ChannelTaskRow({
currentPubkey,
marker,
message,
onOpenAgentConversation,
onGoToTaskMessage,
profiles,
threadMessage,
}: {
currentPubkey?: string;
marker: AgentConversationMarker;
message: TimelineMessage | null;
onOpenAgentConversation?: (
message: TimelineMessage,
options?: { publishMarker?: boolean },
) => void;
onGoToTaskMessage?: (
marker: AgentConversationMarker,
message: TimelineMessage,
threadMessage: TimelineMessage,
) => void;
profiles?: UserProfileLookup;
threadMessage: TimelineMessage | null;
}) {
const startedAt = marker.startedAt || marker.createdAt;
const starterName = resolveUserLabel({
currentPubkey,
profiles,
pubkey: marker.starterPubkey,
});
return (
<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 || !message || !threadMessage}
onClick={() => {
if (message && threadMessage) {
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 || !message}
onClick={() => {
if (message) {
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) ?? null;
const resolvedThreadMessage =
messageById.get(marker.threadRootMessageId ?? "") ??
messageById.get(marker.threadRootId) ??
messageById.get(marker.parentMessageId ?? "") ??
null;
const threadMessage =
resolvedThreadMessage ??
(marker.threadRootId === marker.agentReplyId ? message : null);
return {
marker,
message,
threadMessage,
};
})
.sort(
(left, right) =>
(right.marker.startedAt || right.marker.createdAt) -
(left.marker.startedAt || left.marker.createdAt) ||
right.marker.eventId.localeCompare(left.marker.eventId),
);
}, [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,
@@ -615,11 +387,13 @@ export const ChannelPane = React.memo(function ChannelPane({
threadMessage: TimelineMessage,
) => {
onSurfaceTabChange?.("messages");
const isBroadcastTask = isBroadcastReply(message.tags ?? []);
const isThreadedTask =
Boolean(marker.parentMessageId) ||
Boolean(message.parentId) ||
Boolean(message.rootId && message.rootId !== message.id) ||
threadMessage.id !== message.id;
!isBroadcastTask &&
(Boolean(marker.parentMessageId) ||
Boolean(message.parentId) ||
Boolean(message.rootId && message.rootId !== message.id) ||
threadMessage.id !== message.id);
if (isThreadedTask) {
onOpenThread(threadMessage);
@@ -996,6 +770,9 @@ export const ChannelPane = React.memo(function ChannelPane({
activeChannel={activeChannel}
agentConversationMarkers={agentConversationMarkers}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
messages={messages}
onOpenAgentConversation={handleOpenAgentConversation}
onGoToTaskMessage={handleGoToTaskMessage}
@@ -876,10 +876,12 @@ export function ChannelScreen({
setThreadReplyTargetId(null);
handleCloseAgentSession();
setProfilePanelPubkey(null);
handleSurfaceTabChange("messages");
setChannelManagementOpen(true);
}, [
activeChannel?.channelType,
channelManagementOpen,
handleSurfaceTabChange,
openGlobalChannelManagement,
setChannelManagementOpen,
setOpenThreadHeadId,
@@ -0,0 +1,293 @@
import * as React from "react";
import { ClipboardPlus } from "lucide-react";
import type { AgentConversationMarker } from "@/features/agents/agentConversations";
import {
formatDayHeading,
formatTime,
} from "@/features/messages/lib/dateFormatters";
import type { TimelineMessage } from "@/features/messages/types";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
type ChannelTaskItem = {
marker: AgentConversationMarker;
message: TimelineMessage | null;
threadMessage: TimelineMessage | null;
};
type ChannelTasksViewProps = {
activeChannel: Channel | null;
agentConversationMarkers?: readonly AgentConversationMarker[];
currentPubkey?: string;
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
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>;
};
function formatTaskStartedAt(unixSeconds: number): string {
return `${formatDayHeading(unixSeconds)} at ${formatTime(unixSeconds)}`;
}
function ChannelTaskRow({
currentPubkey,
marker,
message,
onOpenAgentConversation,
onGoToTaskMessage,
profiles,
threadMessage,
}: {
currentPubkey?: string;
marker: AgentConversationMarker;
message: TimelineMessage | null;
onOpenAgentConversation?: (
message: TimelineMessage,
options?: { publishMarker?: boolean },
) => void;
onGoToTaskMessage?: (
marker: AgentConversationMarker,
message: TimelineMessage,
threadMessage: TimelineMessage,
) => void;
profiles?: UserProfileLookup;
threadMessage: TimelineMessage | null;
}) {
const startedAt = marker.startedAt || marker.createdAt;
const starterName = resolveUserLabel({
currentPubkey,
profiles,
pubkey: marker.starterPubkey,
});
return (
<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 || !message || !threadMessage}
onClick={() => {
if (message && threadMessage) {
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 || !message}
onClick={() => {
if (message) {
onOpenAgentConversation?.(message, { publishMarker: false });
}
}}
type="button"
variant="outline"
>
Open
</Button>
</div>
</div>
</article>
);
}
export function ChannelTasksView({
activeChannel,
agentConversationMarkers,
currentPubkey,
messages,
fetchOlder,
hasOlderMessages,
isFetchingOlder,
onOpenAgentConversation,
onGoToTaskMessage,
profiles,
scrollContainerRef,
}: ChannelTasksViewProps) {
const loadOlderRef = React.useRef<HTMLDivElement | null>(null);
const messageById = React.useMemo(
() => new Map(messages.map((message) => [message.id, message])),
[messages],
);
const canLoadOlderTasks = Boolean(fetchOlder && hasOlderMessages);
const handleLoadOlderTasks = React.useCallback(() => {
if (!fetchOlder || isFetchingOlder) {
return;
}
void fetchOlder();
}, [fetchOlder, isFetchingOlder]);
React.useEffect(() => {
if (!canLoadOlderTasks || isFetchingOlder) {
return;
}
const root = scrollContainerRef.current;
const target = loadOlderRef.current;
if (!root || !target) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
handleLoadOlderTasks();
}
},
{ root, rootMargin: "160px 0px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [
canLoadOlderTasks,
handleLoadOlderTasks,
isFetchingOlder,
scrollContainerRef,
]);
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) ?? null;
const resolvedThreadMessage =
messageById.get(marker.threadRootMessageId ?? "") ??
messageById.get(marker.threadRootId) ??
messageById.get(marker.parentMessageId ?? "") ??
null;
const threadMessage =
resolvedThreadMessage ??
(marker.threadRootId === marker.agentReplyId ? message : null);
return {
marker,
message,
threadMessage,
};
})
.sort(
(left, right) =>
(right.marker.startedAt || right.marker.createdAt) -
(left.marker.startedAt || left.marker.createdAt) ||
right.marker.eventId.localeCompare(left.marker.eventId),
);
}, [activeChannel?.id, agentConversationMarkers, messageById]);
const olderTasksLoader = canLoadOlderTasks ? (
<div className="flex justify-center px-3 pt-2" ref={loadOlderRef}>
<Button
className="h-8 rounded-lg px-3 text-xs font-medium"
disabled={isFetchingOlder}
onClick={handleLoadOlderTasks}
type="button"
variant="ghost"
>
{isFetchingOlder ? "Loading older tasks..." : "Load older tasks"}
</Button>
</div>
) : null;
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>
{olderTasksLoader}
</>
) : (
<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}
/>
))}
{olderTasksLoader}
</div>
)}
</div>
</div>
</div>
);
}