Files
buzz/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx

239 lines
8.1 KiB
TypeScript

import * as React from "react";
import { CheckCheck } from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { cn } from "@/shared/lib/cn";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import { MessageLinkHoverCue } from "../activityRenderClasses/MessageLinkHoverCue";
import { TranscriptTimestamp } from "../activityRenderClasses/TranscriptTimestamp";
import { useTranscriptBubbleOverflow } from "../activityRenderClasses/useTranscriptBubbleOverflow";
import { compactSummaryTone } from "./CompactToolSummaryRow";
import type { SentMessageLink } from "./messageLinks";
import { SentMessageContextDialog } from "./SentMessageContextDialog";
import { useSentMessageBody } from "./useSentMessageBody";
export function CompactMessageSummary({
args,
avatarUrl,
description,
displayName,
duration,
hasArgs,
hasResult,
isError,
label,
messageLink,
preview,
pubkey,
result,
timestamp,
}: {
args: Record<string, unknown>;
avatarUrl: string | null;
description?: string;
displayName: string;
duration: string | null;
hasArgs: boolean;
hasResult: boolean;
isError: boolean;
label: string;
messageLink: SentMessageLink | null;
preview: string | null;
pubkey: string;
result: string;
timestamp: string;
}) {
const [detailsOpen, setDetailsOpen] = React.useState(false);
const resolvedContent = useSentMessageBody(messageLink, preview);
const variant = useAgentSessionTranscriptVariant();
const { goChannel } = useAppNavigation();
const { openProfilePanel } = useProfilePanel();
const isCompactPreview = variant === "compactPreview";
const isConversation = variant === "conversation";
// Focus mode is a reading view, so sent messages remain fully visible there.
// The default activity surface keeps the compact clamp.
const shouldClampBubble = !isCompactPreview && !isConversation;
const [bubbleRef, hasBubbleOverflow] =
useTranscriptBubbleOverflow(shouldClampBubble);
const canOpenMessage = !isCompactPreview && messageLink !== null;
const mutedTone = compactSummaryTone();
const avatarClassName = cn(
"mr-2 mt-1 shrink-0",
isCompactPreview ? "size-5" : "size-7",
);
const handleBubbleClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!messageLink || isNestedInteractiveTarget(event)) return;
event.preventDefault();
event.stopPropagation();
void goChannel(messageLink.channelId, {
messageId: messageLink.messageId,
});
},
[goChannel, messageLink],
);
const handleBubbleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (
!messageLink ||
isNestedInteractiveTarget(event) ||
(event.key !== "Enter" && event.key !== " ")
) {
return;
}
event.preventDefault();
event.stopPropagation();
void goChannel(messageLink.channelId, {
messageId: messageLink.messageId,
});
},
[goChannel, messageLink],
);
const bubbleLinkProps = canOpenMessage
? {
onClick: handleBubbleClick,
onKeyDown: handleBubbleKeyDown,
role: "link" as const,
tabIndex: 0,
}
: {};
return (
<>
<div className="flex max-w-full flex-row items-start justify-start">
{openProfilePanel && !isCompactPreview ? (
<button
aria-label={`Open ${displayName} profile`}
className={cn(
avatarClassName,
"pointer-events-auto rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openProfilePanel(pubkey);
}}
type="button"
>
<UserAvatar
avatarUrl={avatarUrl}
className="size-full text-xs"
displayName={displayName}
size="sm"
testId="transcript-agent-sent-avatar"
/>
</button>
) : (
<UserAvatar
avatarUrl={avatarUrl}
className={cn(
avatarClassName,
isCompactPreview ? "text-3xs" : "text-xs",
)}
displayName={displayName}
size="sm"
testId="transcript-agent-sent-avatar"
/>
)}
<div
className={cn(
"flex min-w-0 flex-1 flex-col items-start gap-1",
isConversation && "pr-9",
)}
>
<div
className={cn(
"w-full min-w-0 rounded-2xl border px-3 py-2",
isCompactPreview
? "text-xs leading-4"
: "text-sm leading-relaxed",
shouldClampBubble && "relative max-h-36 overflow-hidden",
isConversation && "relative pr-4",
canOpenMessage &&
"group/bubble cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isCompactPreview
? isError
? "border-destructive/25 bg-destructive/10 text-destructive"
: "border-transparent bg-muted text-foreground"
: isError
? "border-destructive/25 bg-destructive/10 text-destructive"
: "border-transparent bg-muted text-foreground",
canOpenMessage &&
(isError ? "hover:bg-destructive/15" : "hover:bg-muted/90"),
)}
data-testid="transcript-tool-message-preview"
ref={bubbleRef}
{...bubbleLinkProps}
>
<Markdown
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
content={resolvedContent || "Message content unavailable."}
/>
{hasBubbleOverflow ? (
<span
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 h-8 rounded-b-2xl bg-linear-to-b from-transparent",
isError
? "to-destructive/10"
: isCompactPreview
? "to-muted"
: "to-muted",
)}
/>
) : null}
{canOpenMessage ? <MessageLinkHoverCue /> : null}
</div>
<div className="inline-flex max-w-full items-center gap-1.5 px-1">
<TranscriptTimestamp
messageLink={messageLink}
timestamp={timestamp}
/>
<button
aria-label="Show sent message context"
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
mutedTone,
)}
data-testid="transcript-sent-message-context-button"
onClick={() => setDetailsOpen(true)}
title="Show sent message context"
type="button"
>
<CheckCheck className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
<SentMessageContextDialog
args={args}
description={description}
duration={duration}
hasArgs={hasArgs}
hasResult={hasResult}
isError={isError}
label={label}
onOpenChange={setDetailsOpen}
open={detailsOpen}
preview={preview}
result={result}
/>
</>
);
}
function isNestedInteractiveTarget(
event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>,
) {
const target =
event.target instanceof Element
? event.target.closest(
"a,button,input,select,textarea,summary,[role='button'],[role='link']",
)
: null;
return target !== null && target !== event.currentTarget;
}