feat(profile): embed live activity feed in profile aux panel (#1380)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-07-03 08:40:29 -07:00
committed by GitHub
parent 204a0fd2ec
commit 654b6c374b
70 changed files with 4309 additions and 670 deletions

View File

@@ -59,6 +59,7 @@
"@tiptap/starter-kit": "^3.22.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"emoji-mart": "^5.6.0",
"jdenticon": "^3.3.0",
"lucide-react": "^1.0.0",

View File

@@ -443,6 +443,19 @@ export function injectObserverEventsForE2E(
notifyListeners();
}
/**
* Synchronize the observer store with a sorted buffer of events for one agent.
* Used by test harnesses and replay bridges that already hold decoded frames.
*/
export function syncAgentObserverEvents(
agentPubkey: string,
events: ObserverEvent[],
) {
for (const event of events) {
appendAgentEvent(agentPubkey, event);
}
}
export function resetAgentObserverStore() {
generation += 1;
const unsubscribe = unsubscribeRelay;

View File

@@ -1,9 +1,15 @@
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";
@@ -20,6 +26,7 @@ export function CompactMessageSummary({
label,
messageLink,
preview,
pubkey,
result,
timestamp,
}: {
@@ -34,34 +41,139 @@ export function CompactMessageSummary({
label: string;
messageLink: SentMessageLink | null;
preview: string | null;
pubkey: string;
result: string;
timestamp: string;
}) {
const [detailsOpen, setDetailsOpen] = React.useState(false);
const variant = useAgentSessionTranscriptVariant();
const { goChannel } = useAppNavigation();
const { openProfilePanel } = useProfilePanel();
const isCompactPreview = variant === "compactPreview";
const shouldClampBubble = !isCompactPreview;
const [bubbleRef, hasBubbleOverflow] =
useTranscriptBubbleOverflow(shouldClampBubble);
const canOpenMessage = shouldClampBubble && 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">
<UserAvatar
avatarUrl={avatarUrl}
className="mr-2 mt-1 shrink-0"
displayName={displayName}
size="xs"
testId="transcript-agent-sent-avatar"
/>
<div className="flex max-w-[85%] min-w-0 flex-col items-start gap-1">
{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="flex min-w-0 flex-1 flex-col items-start gap-1">
<div
className={cn(
"min-w-0 rounded-2xl border px-3 py-2 text-sm leading-relaxed shadow-sm",
isError
? "border-destructive/25 bg-destructive/10 text-destructive"
: "border-primary/15 bg-primary/6 text-foreground",
"w-full min-w-0 rounded-2xl border px-3 py-2 shadow-sm",
isCompactPreview
? "text-xs leading-4"
: "text-sm leading-relaxed",
shouldClampBubble && "relative max-h-36 overflow-hidden",
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}
>
<p className="whitespace-pre-wrap wrap-break-word">
{preview || "Message content unavailable."}
</p>
<Markdown
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
content={preview || "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
@@ -100,3 +212,16 @@ export function CompactMessageSummary({
</>
);
}
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;
}

View File

@@ -2,10 +2,13 @@ import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import type { AgentActivityAction } from "../agentSessionTypes";
import type { CompactFileEditSummary } from "../agentSessionToolSummary";
import { isInlineImageData } from "../agentSessionUtils";
import type {
CompactFileEditSummary,
CompactToolKind,
} from "../agentSessionToolSummary";
import { resolveToolImageSrc } from "../agentSessionUtils";
import {
ActivityRowLabel,
splitActivityRowLabel,
@@ -13,13 +16,14 @@ import {
} from "../activityRenderClasses/ActivityRow";
export function compactSummaryTone() {
return "text-muted-foreground/60 group-open:text-foreground";
return "text-muted-foreground/60 transition-colors group-hover/row:text-foreground group-open:text-foreground";
}
export function CompactToolSummaryRow({
action,
duration,
fileEditSummary,
kind,
label,
preview,
thumbnailSrc,
@@ -27,19 +31,22 @@ export function CompactToolSummaryRow({
action: AgentActivityAction | null;
duration: string | null;
fileEditSummary: CompactFileEditSummary | null;
kind: CompactToolKind;
label: string;
preview: string | null;
thumbnailSrc: string | null;
}) {
const [thumbnailFailed, setThumbnailFailed] = React.useState(false);
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
const mutedTone = compactSummaryTone();
const resolvedThumbnail = React.useMemo(() => {
if (!thumbnailSrc || thumbnailFailed) return null;
return resolveImageSrc(thumbnailSrc);
return resolveToolImageSrc(thumbnailSrc);
}, [thumbnailFailed, thumbnailSrc]);
const actionLabel = fileEditSummary
? null
: getCompactToolActionLabel(action, label, preview);
: getCompactToolActionLabel(action, kind, label, preview);
return (
<>
@@ -53,7 +60,13 @@ export function CompactToolSummaryRow({
verb={actionLabel.verb}
/>
) : (
<span className={cn("shrink-0 text-sm font-semibold", mutedTone)}>
<span
className={cn(
"shrink-0 font-semibold",
isCompactPreview ? "text-xs" : "text-sm",
mutedTone,
)}
>
{label}
</span>
)}
@@ -69,7 +82,11 @@ export function CompactToolSummaryRow({
/>
) : !fileEditSummary && !actionLabel && preview ? (
<span
className={cn("min-w-0 max-w-48 truncate text-sm", mutedTone)}
className={cn(
"min-w-0 max-w-48 truncate",
isCompactPreview ? "text-xs" : "text-sm",
mutedTone,
)}
title={preview}
>
{preview}
@@ -90,6 +107,7 @@ export function CompactToolSummaryRow({
function getCompactToolActionLabel(
action: AgentActivityAction | null,
kind: CompactToolKind,
label: string,
preview: string | null,
): (ActivityRowLabelParts & { title?: string }) | null {
@@ -108,10 +126,11 @@ function getCompactToolActionLabel(
if (!preview) return parts;
if (
label === "Ran command" ||
label === "Read file" ||
label === "Updated todos" ||
label === "Viewed image"
kind === "shell" ||
kind === "file-read" ||
kind === "skill-read" ||
kind === "plan" ||
kind === "image"
) {
return { verb: parts.verb, object: preview, title: preview };
}
@@ -138,7 +157,3 @@ function CompactFileEditSummaryView({
/>
);
}
function resolveImageSrc(source: string): string {
return isInlineImageData(source) ? source : rewriteRelayUrl(source);
}

View File

@@ -1,5 +1,6 @@
import { Terminal } from "lucide-react";
import { ScrollFadeMonoPanel } from "../FileContentBlock";
import { parseShellToolOutput } from "../agentSessionUtils";
export function ShellCommandBlock({
@@ -14,17 +15,28 @@ export function ShellCommandBlock({
return (
<div
className="rounded-lg bg-muted/40 px-3 py-2 font-mono text-xs leading-5"
className="overflow-hidden rounded-lg bg-muted font-mono text-xs leading-5"
data-testid="transcript-shell-command"
>
<p className="whitespace-pre-wrap wrap-break-word text-muted-foreground/70">
<Terminal className="mr-2 inline h-3.5 w-3.5 align-[-0.1875rem] text-accent-foreground" />
{command}
</p>
<ScrollFadeMonoPanel
fadeFromClassName="from-muted"
maxHeightClassName="max-h-36"
>
<p className="whitespace-pre-wrap wrap-break-word text-muted-foreground/70">
<Terminal className="mr-2 inline h-3.5 w-3.5 align-[-0.1875rem] text-primary" />
{command}
</p>
</ScrollFadeMonoPanel>
{stdout ? (
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap wrap-break-word text-foreground">
{stdout}
</pre>
<ScrollFadeMonoPanel
className="mt-2"
fadeFromClassName="from-muted"
maxHeightClassName="max-h-36"
>
<pre className="whitespace-pre-wrap wrap-break-word text-foreground">
{stdout}
</pre>
</ScrollFadeMonoPanel>
) : null}
</div>
);

View File

@@ -1,5 +1,7 @@
import { cn } from "@/shared/lib/cn";
import type { TranscriptItem } from "../agentSessionTypes";
import type { CompactToolSummary } from "../agentSessionToolSummary";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import {
asRecord,
formatTranscriptTimestampTitle,
@@ -27,6 +29,8 @@ export function TodoToolSummary({
item: Extract<TranscriptItem, { type: "tool" }>;
}) {
const todos = buildTodoDisplayItems(item.args, item.result, fallbackPreview);
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
const actionLabel = {
verb: "Updated",
object: fallbackPreview ?? "todos",
@@ -57,7 +61,14 @@ export function TodoToolSummary({
))}
</div>
) : (
<p className="text-sm text-muted-foreground/80">No todos.</p>
<p
className={cn(
"text-muted-foreground/80",
isCompactPreview ? "text-xs" : "text-sm",
)}
>
No todos.
</p>
)}
</ActivityRowContent>
</ActivityRow>
@@ -72,8 +83,16 @@ export function isTodoSummary(summary: CompactToolSummary) {
}
function TodoCheckboxRow({ todo }: { todo: TodoDisplayItem }) {
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
return (
<div className="flex min-w-0 items-start gap-2 text-sm leading-5 text-muted-foreground">
<div
className={cn(
"flex min-w-0 items-start gap-2 leading-5 text-muted-foreground",
isCompactPreview ? "text-xs" : "text-sm",
)}
>
<input
checked={todo.checked}
className="mt-0.5 h-3.5 w-3.5 shrink-0 cursor-default accent-primary"

View File

@@ -1,5 +1,7 @@
import { cn } from "@/shared/lib/cn";
import type { FileEditDiff } from "../agentSessionFileEditDiff";
import type { FileReadContent } from "../agentSessionFileRead";
import { FileContentBlock } from "../FileContentBlock";
import { FileEditDiffBlock, hasFileEditLineDiff } from "../FileEditDiffView";
import { formatCodeValue } from "../agentSessionUtils";
import { ShellCommandBlock } from "./ShellCommandBlock";
@@ -9,6 +11,7 @@ export function ToolDetailBlocks({
args,
description,
fileEditDiff,
fileReadContent,
hasArgs,
hasResult,
imagePreview,
@@ -19,6 +22,7 @@ export function ToolDetailBlocks({
args: Record<string, unknown>;
description?: string;
fileEditDiff: FileEditDiff | null;
fileReadContent: FileReadContent | null;
hasArgs: boolean;
hasResult: boolean;
imagePreview: { src: string | null; title: string | null } | null;
@@ -28,8 +32,10 @@ export function ToolDetailBlocks({
}) {
const showFileEditDiff =
fileEditDiff && hasFileEditLineDiff(fileEditDiff) && !isError;
const showShellCommand = shellCommand != null && !showFileEditDiff;
const showParameters = hasArgs && !showFileEditDiff;
const showFileReadContent = fileReadContent != null && !isError;
const showFileContent = showFileEditDiff || showFileReadContent;
const showShellCommand = shellCommand != null && !showFileContent;
const showParameters = hasArgs && !showFileContent;
return (
<div className="space-y-4 py-2 text-popover-foreground outline-hidden">
@@ -56,6 +62,13 @@ export function ToolDetailBlocks({
{!showShellCommand && hasResult ? (
showFileEditDiff ? (
<FileEditDiffBlock diff={fileEditDiff} />
) : showFileReadContent ? (
<FileContentBlock
footerText={fileReadContent.footerText}
footerTitle={fileReadContent.footerTitle}
lines={fileReadContent.lines}
path={fileReadContent.path}
/>
) : (
<ToolCodeBlock
label={isError ? "Error" : "Result"}

View File

@@ -13,7 +13,6 @@ import type { AgentTranscriptIdentityProps } from "../activityRenderClasses/type
import {
formatTranscriptTimestampTitle,
getToolDurationDisplay,
getToolString,
} from "../agentSessionUtils";
import { CompactMessageSummary } from "./CompactMessageSummary";
import {
@@ -77,6 +76,7 @@ export function ToolItem({
label={compactSummary.label}
messageLink={messageLink}
preview={compactSummary.preview}
pubkey={agentPubkey}
result={item.result}
timestamp={item.timestamp}
/>
@@ -113,9 +113,7 @@ export function ToolItem({
>
<summary
className={cn(
"max-w-full cursor-pointer list-none",
compactSummary.presentation === "inline" &&
"inline-flex items-center gap-1.5",
"group/row flex min-h-6 max-w-full cursor-pointer list-none items-center gap-1.5",
compactSummaryTone(),
)}
>
@@ -123,6 +121,7 @@ export function ToolItem({
action={compactSummary.action}
duration={duration}
fileEditSummary={compactSummary.fileEditSummary}
kind={compactSummary.kind}
preview={compactSummary.preview}
thumbnailSrc={compactSummary.thumbnailSrc}
label={compactSummary.label}
@@ -133,25 +132,20 @@ export function ToolItem({
args={item.args}
description={buzzTool?.label}
fileEditDiff={compactSummary.fileEditDiff}
fileReadContent={compactSummary.fileReadContent}
hasArgs={hasArgs}
hasResult={hasResult}
imagePreview={
compactSummary.thumbnailSrc != null && isExpanded
compactSummary.imageContent != null && isExpanded
? {
src: compactSummary.thumbnailSrc,
title: compactSummary.preview,
src: compactSummary.imageContent.src,
title: compactSummary.imageContent.title,
}
: null
}
isError={item.isError}
result={item.result}
shellCommand={
compactSummary.kind === "shell"
? (getToolString(item.args, ["command"]) ??
compactSummary.preview ??
"command")
: null
}
shellCommand={compactSummary.shellContent}
/>
</details>
</div>

View File

@@ -1,8 +1,7 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { isInlineImageData } from "../agentSessionUtils";
import { SimpleImageLightbox } from "@/shared/ui/SimpleImageLightbox";
import { resolveToolImageSrc } from "../agentSessionUtils";
export function ViewImageToolPreview({
src,
@@ -13,7 +12,7 @@ export function ViewImageToolPreview({
}) {
const [lightboxOpen, setLightboxOpen] = React.useState(false);
const [imageFailed, setImageFailed] = React.useState(false);
const resolvedSrc = React.useMemo(() => resolveImageSrc(src), [src]);
const resolvedSrc = React.useMemo(() => resolveToolImageSrc(src), [src]);
const alt = title ?? "Viewed image";
if (imageFailed) {
@@ -33,7 +32,7 @@ export function ViewImageToolPreview({
src={resolvedSrc}
title={title ?? undefined}
/>
<ImageLightbox
<SimpleImageLightbox
alt={alt}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
@@ -42,67 +41,3 @@ export function ViewImageToolPreview({
</>
);
}
function ImageLightbox({
alt,
onOpenChange,
open,
src,
}: {
alt: string;
onOpenChange: (open: boolean) => void;
open: boolean;
src: string;
}) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
onInteractOutside={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">
{alt}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size image preview. Press Escape or click outside the image to
close.
</DialogPrimitive.Description>
<DialogPrimitive.Close
aria-label="Close lightbox"
className="absolute inset-0 cursor-default"
/>
<img
alt={alt}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={src}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6l12 12"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
function resolveImageSrc(source: string): string {
return isInlineImageData(source) ? source : rewriteRelayUrl(source);
}

View File

@@ -1,16 +1,32 @@
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { CheckCheck, Radio } from "lucide-react";
import {
useActiveAgentTurns,
type ActiveTurnSummary,
} from "@/features/agents/activeAgentTurnsStore";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { cn } from "@/shared/lib/cn";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import type { TranscriptItem } from "./agentSessionTypes";
import { Toggle } from "@/shared/ui/toggle";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { TurnLivenessIndicator } from "./TurnLivenessIndicator";
import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion";
import {
AgentSessionTranscriptVariantProvider,
type AgentSessionTranscriptVariant,
useAgentSessionTranscriptVariant,
} from "./agentSessionTranscriptContext";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
import {
ActivityRow,
@@ -37,12 +53,35 @@ import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";
const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";
const ROW_ENTER_SPRING = {
damping: 38,
stiffness: 480,
type: "spring",
} as const;
const ROW_ENTER_FROM = { opacity: 0, y: 12 } as const;
const ROW_ENTER_TO = { opacity: 1, y: 0 } as const;
/**
* False during the mount commit, true afterwards. Children mounted with the
* initial batch (history load) read false and skip their enter animation;
* children appended later read true and animate in.
*/
function useHasCompletedInitialRender() {
const ref = React.useRef(false);
React.useEffect(() => {
ref.current = true;
}, []);
return ref;
}
/**
* Opt-in only: source pills are useful while iterating on observer parsing, but
* they should not appear for every local dev session.
*/
const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource();
export type AgentSessionTranscriptEmptyState = "idle" | "loading";
function shouldShowTranscriptAcpSource() {
const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE;
if (envValue === "1" || envValue === "true") {
@@ -66,56 +105,197 @@ export function AgentSessionTranscriptList({
agentAvatarUrl,
agentName,
agentPubkey,
autoTail = false,
channelId = null,
emptyDescription,
emptyState = "idle",
items,
profiles,
contentContainerClassName,
scrollScopeKey,
variant = "default",
}: AgentTranscriptIdentityProps & {
autoTail?: boolean;
channelId?: string | null;
emptyDescription: string;
emptyState?: AgentSessionTranscriptEmptyState;
items: TranscriptItem[];
profiles?: UserProfileLookup;
contentContainerClassName?: string;
scrollScopeKey?: string | null;
variant?: AgentSessionTranscriptVariant;
}) {
const activeTurns = useActiveAgentTurns(agentPubkey);
const isTurnLive = React.useMemo(
() => isAgentTurnLive(activeTurns, channelId),
[activeTurns, channelId],
);
const displayBlocks = React.useMemo(
() => buildTranscriptDisplayBlocks(items),
[items],
);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const anchoredScroll = useAnchoredScroll({
channelId: autoTail ? (scrollScopeKey ?? agentPubkey) : null,
contentRef,
isLoading: false,
messages: items,
scrollContainerRef,
});
const isCompactPreview = variant === "compactPreview";
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const shouldReduceMotion = useReducedMotion();
const animationsDisabled =
Boolean(shouldReduceMotion) || !animationPreferenceEnabled;
// Position (layout) animations are only safe when this component owns the
// scroll container: `layoutScroll` below tells motion to subtract our scroll
// offset when measuring rows. When an ancestor scrolls instead (autoTail
// off), scrolling would register as false position deltas and rows would
// visibly spring back toward their pre-scroll position, so only the enter
// animation runs there.
const layoutAnimationsEnabled = !animationsDisabled && autoTail;
const hasCompletedInitialRenderRef = useHasCompletedInitialRender();
const hasRenderableContent =
items.length > 0 && hasRenderableDisplayContent(displayBlocks, variant);
const scrollContainerClassNames = cn(
"w-full",
autoTail ? "h-full overflow-y-auto" : null,
);
if (!hasRenderableContent) {
const isLoading = emptyState === "loading" || isTurnLive;
if (items.length === 0) {
return (
<div className="flex min-h-40 flex-col items-center justify-center px-6 py-10 text-center">
<Radio className="mx-auto h-4 w-4 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No ACP activity yet</p>
<p className="mt-1 text-sm text-muted-foreground">{emptyDescription}</p>
<div className={scrollContainerClassNames}>
<div className="flex h-full min-h-40 flex-col items-center justify-center px-6 py-10 text-center">
{isLoading ? (
<FuzzyLogo
ariaLabel="Waiting for ACP activity"
className="mx-auto text-muted-foreground"
fuzz={false}
loop
/>
) : (
<>
<Radio className="mx-auto h-4 w-4 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No ACP activity yet</p>
<p className="mt-1 text-sm text-muted-foreground">
{emptyDescription}
</p>
</>
)}
</div>
</div>
);
}
return (
<div className="w-full">
<motion.div
className={scrollContainerClassNames}
layoutScroll
onScroll={autoTail ? anchoredScroll.onScroll : undefined}
ref={autoTail ? scrollContainerRef : undefined}
>
<div
aria-label="Live ACP transcript"
aria-live="polite"
className="flex w-full flex-col gap-2.5"
className={cn(
"flex w-full flex-col",
isCompactPreview ? "gap-1" : "gap-4",
autoTail && "pb-4",
contentContainerClassName,
)}
ref={autoTail ? contentRef : undefined}
role="log"
>
{displayBlocks.map((block) => (
<div
className="content-visibility-auto"
key={getDisplayBlockKey(block)}
>
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={block}
profiles={profiles}
/>
</div>
))}
<AgentSessionTranscriptVariantProvider value={variant}>
{displayBlocks.map((block) => {
const blockKey = getDisplayBlockKey(block);
return (
<motion.div
animate={ROW_ENTER_TO}
data-message-id={blockKey}
initial={
animationsDisabled || !hasCompletedInitialRenderRef.current
? false
: ROW_ENTER_FROM
}
key={blockKey}
layout={layoutAnimationsEnabled ? "position" : false}
transition={ROW_ENTER_SPRING}
>
{/* content-visibility stays on a non-animated child: motion
measures the outer wrapper for layout animations, which
would otherwise force skipped offscreen rows to render. */}
<div className="content-visibility-auto">
<TranscriptDisplayBlockView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
block={block}
profiles={profiles}
/>
</div>
</motion.div>
);
})}
{isTurnLive && !isCompactPreview ? <TurnLivenessIndicator /> : null}
</AgentSessionTranscriptVariantProvider>
</div>
</div>
</motion.div>
);
}
function isAgentTurnLive(
activeTurns: ActiveTurnSummary[],
channelId: string | null,
) {
if (activeTurns.length === 0) {
return false;
}
if (!channelId) {
return true;
}
return activeTurns.some((turn) => turn.channelId === channelId);
}
function hasRenderableDisplayContent(
displayBlocks: TranscriptDisplayBlock[],
variant: AgentSessionTranscriptVariant,
) {
if (variant !== "compactPreview") {
return displayBlocks.length > 0;
}
return displayBlocks.some(hasRenderableCompactBlock);
}
function hasRenderableCompactBlock(block: TranscriptDisplayBlock) {
if (block.kind === "single") {
return isRenderableCompactItem(block.item);
}
return block.segments.some((segment) => {
if (segment.kind === "item") {
return isRenderableCompactItem(segment.item);
}
if (segment.kind === "prompt") {
return true;
}
if (segment.kind === "summary") {
return segment.summary.items.some(isRenderableCompactItem);
}
return false;
});
}
function isRenderableCompactItem(item: TranscriptItem) {
return item.renderClass !== "raw-rail" && item.renderClass !== "suppressed";
}
function TranscriptAcpSourceBadge({ source }: { source: string }) {
return (
<span
@@ -145,6 +325,18 @@ function TranscriptDisplayBlockView({
block: TranscriptDisplayBlock;
profiles?: UserProfileLookup;
}) {
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const shouldReduceMotion = useReducedMotion();
// Streaming tool calls land as new segments inside the current turn block
// (the block key stays `turn:<id>`), so the list-level enter animation
// never fires for them — each segment animates in here instead. Segments
// present when the block mounts (history load, or the first paint of a new
// turn — the block wrapper already animates that) skip the transition.
const hasCompletedInitialRenderRef = useHasCompletedInitialRender();
const animateSegmentEnter = animationPreferenceEnabled && !shouldReduceMotion;
if (block.kind === "single") {
return (
<TranscriptItemRow
@@ -159,19 +351,29 @@ function TranscriptDisplayBlockView({
return (
<div
className="flex flex-col gap-2.5"
className={cn("flex flex-col", isCompactPreview ? "gap-2.5" : "gap-4")}
data-testid="transcript-turn-group"
data-turn-id={block.turnId}
>
{block.segments.map((segment) => (
<TranscriptTurnSegmentView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
<motion.div
animate={ROW_ENTER_TO}
initial={
animateSegmentEnter && hasCompletedInitialRenderRef.current
? ROW_ENTER_FROM
: false
}
key={getTurnSegmentKey(block.turnId, segment)}
profiles={profiles}
segment={segment}
/>
transition={ROW_ENTER_SPRING}
>
<TranscriptTurnSegmentView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
profiles={profiles}
segment={segment}
/>
</motion.div>
))}
</div>
);
@@ -398,89 +600,71 @@ function PromptUserMessage({
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
systemPrompt?: Extract<TranscriptItem, { type: "metadata" }> | null;
}) {
const [contextOpen, setContextOpen] = React.useState(false);
const contextSections = React.useMemo(
() => [...(systemPrompt?.sections ?? []), ...(context?.sections ?? [])],
[context, systemPrompt],
);
return (
<>
<UserMessageBubble
bubbleClassName="p-2.5"
footer={
<TurnSetupFooter
contextOpen={contextOpen}
hasContext={contextSections.length > 0}
items={setup}
messageLink={getTranscriptMessageLink(item)}
onContextOpenChange={setContextOpen}
timestamp={item.timestamp}
/>
}
item={item}
profiles={profiles}
/>
{systemPrompt && systemPrompt.sections.length > 0 ? (
<PromptContextInline context={systemPrompt} />
) : null}
{context && context.sections.length > 0 ? (
<PromptContextInline context={context} />
) : null}
</>
);
}
function PromptContextInline({
context,
}: {
context: Extract<TranscriptItem, { type: "metadata" }>;
}) {
const [dialogOpen, setDialogOpen] = React.useState(false);
return (
<>
<div
className="mt-1 space-y-2 pl-2"
data-testid="transcript-prompt-context-inline"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground/70">
{context.title}
</p>
<button
className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors"
data-testid="transcript-prompt-context-expand"
onClick={() => setDialogOpen(true)}
type="button"
>
View full
</button>
</div>
<PromptContextSections sections={context.sections} />
</div>
<PromptContextDialog
context={context}
onOpenChange={setDialogOpen}
open={dialogOpen}
onOpenChange={setContextOpen}
open={contextOpen}
sections={contextSections}
setup={setup}
/>
</>
);
}
function PromptContextDialog({
context,
onOpenChange,
open,
sections,
setup,
}: {
context: Extract<TranscriptItem, { type: "metadata" }>;
onOpenChange: (open: boolean) => void;
open: boolean;
sections: PromptSection[];
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
}) {
if (!open || context.sections.length === 0) {
if (!open || sections.length === 0) {
return null;
}
const setupText = formatPromptSetupSummary(setup);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
<DialogTitle>{context.title}</DialogTitle>
<DialogTitle>Prompt context</DialogTitle>
{setupText ? (
<div className="flex items-center gap-1.5">
<CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<DialogDescription>{setupText}</DialogDescription>
</div>
) : null}
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
<PromptContextSections sections={context.sections} />
<PromptContextSections sections={sections} />
</div>
</div>
</DialogContent>
@@ -488,14 +672,28 @@ function PromptContextDialog({
);
}
function formatPromptSetupSummary(
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
) {
const label = formatTurnSetupLabel(items);
const detail = turnSetupDetail(items);
return [label, detail].filter(Boolean).join(" · ");
}
function TurnSetupFooter({
contextOpen = false,
hasContext = false,
items,
messageLink = null,
onContextOpenChange,
showTimestamp = true,
timestamp,
}: {
contextOpen?: boolean;
hasContext?: boolean;
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
messageLink?: { channelId: string; messageId: string } | null;
onContextOpenChange?: (open: boolean) => void;
showTimestamp?: boolean;
timestamp: string;
}) {
@@ -503,8 +701,9 @@ function TurnSetupFooter({
const detail = turnSetupDetail(items);
const tooltipText = [label, detail].filter(Boolean).join(" · ");
const showSetup = items.length > 0;
const showContext = hasContext && onContextOpenChange != null;
if (!showSetup) {
if (!showSetup && !showContext) {
return showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null;
@@ -515,10 +714,25 @@ function TurnSetupFooter({
className="flex items-center gap-1.5 text-muted-foreground/80"
data-testid="transcript-turn-setup"
>
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
{showContext ? (
<Toggle
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
data-testid="transcript-prompt-context-toggle"
onPressedChange={onContextOpenChange}
pressed={contextOpen}
size="xs"
title={tooltipText || "Show prompt context"}
variant="ghost"
>
<CheckCheck aria-hidden="true" />
</Toggle>
) : (
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
<CheckCheck className="h-3.5 w-3.5" />
<span className="sr-only">{tooltipText}</span>
</span>
)}
{showTimestamp ? (
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
) : null}

View File

@@ -0,0 +1,107 @@
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
export type FileContentLineKind = "add" | "remove" | "context" | "meta";
export type FileContentLine = {
kind: FileContentLineKind;
text: string;
};
/** Scrollable mono panel with top/bottom fade affordances for overflow. */
export function ScrollFadeMonoPanel({
children,
className,
fadeFromClassName = "from-muted",
maxHeightClassName = "max-h-64",
}: {
children: React.ReactNode;
className?: string;
fadeFromClassName?: string;
maxHeightClassName?: string;
}) {
return (
<div className={cn("relative", className)}>
<div
className={cn(
"overflow-auto font-mono text-xs leading-5",
maxHeightClassName,
)}
>
<div className="px-3 py-2">{children}</div>
</div>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 h-4 bg-linear-to-b to-transparent",
fadeFromClassName,
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-linear-to-t to-transparent",
fadeFromClassName,
)}
/>
</div>
);
}
export function FileContentBlock({
footerText,
footerTitle,
lines,
path,
}: {
footerText?: string;
footerTitle?: string;
lines: FileContentLine[];
path: string;
}) {
const resolvedFooterText = footerText ?? path;
return (
<div className="flex max-h-64 flex-col overflow-hidden rounded-md border border-border/50 bg-muted/35 text-xs leading-5 text-foreground">
<div className="min-h-0 flex-1 overflow-auto">
<pre className="py-2 font-mono">
<FileContentLines lines={lines} />
</pre>
</div>
<div
className="relative z-10 shrink-0 truncate border-t border-border/50 bg-muted/35 px-3 py-1.5 text-xs font-normal leading-5 text-muted-foreground/70"
title={footerTitle ?? resolvedFooterText}
>
{resolvedFooterText}
</div>
</div>
);
}
function FileContentLines({ lines }: { lines: FileContentLine[] }) {
return lines.map((line, index) => (
<FileContentLineView
// biome-ignore lint/suspicious/noArrayIndexKey: file content lines are positional
key={index}
line={line}
/>
));
}
function FileContentLineView({ line }: { line: FileContentLine }) {
return (
<span
className={cn(
"block min-w-full whitespace-pre-wrap wrap-break-word px-3",
line.kind === "add" &&
"border-l-2 border-green-500/50 bg-green-500/12 text-foreground dark:bg-green-500/10",
line.kind === "remove" &&
"border-l-2 border-red-500/50 bg-red-500/12 text-foreground dark:bg-red-500/10",
line.kind === "meta" && "text-muted-foreground/70",
)}
>
{line.text || " "}
</span>
);
}

View File

@@ -1,8 +1,8 @@
import { cn } from "@/shared/lib/cn";
import type {
FileEditDiff,
FileEditDiffLine,
} from "./agentSessionFileEditDiff";
import { FileContentBlock, type FileContentLine } from "./FileContentBlock";
export function hasFileEditLineDiff(diff: FileEditDiff) {
return diff.lines.some(
@@ -12,45 +12,18 @@ export function hasFileEditLineDiff(diff: FileEditDiff) {
export function FileEditDiffBlock({ diff }: { diff: FileEditDiff }) {
return (
<div className="flex max-h-64 flex-col overflow-hidden rounded-md border border-border/50 bg-muted/35 text-xs leading-5 text-foreground">
<pre className="min-h-0 flex-1 overflow-auto py-2 font-mono">
<FileEditDiffLines diff={diff} />
</pre>
<div
className="truncate border-t border-border/50 px-3 py-1.5 text-xs font-normal text-muted-foreground/70"
title={diff.path}
>
{diff.path}
</div>
</div>
<FileContentBlock
lines={diff.lines
.filter((line) => line.kind !== "meta")
.map(toFileContentLine)}
path={diff.path}
/>
);
}
function FileEditDiffLines({ diff }: { diff: FileEditDiff }) {
return diff.lines
.filter((line) => line.kind !== "meta")
.map((line, index) => (
<FileEditDiffLineView
// biome-ignore lint/suspicious/noArrayIndexKey: diff lines are positional
key={index}
line={line}
/>
));
}
function FileEditDiffLineView({ line }: { line: FileEditDiffLine }) {
return (
<span
className={cn(
"block min-w-full whitespace-pre-wrap wrap-break-word px-3",
line.kind === "add" &&
"border-l-2 border-green-500/50 bg-green-500/12 text-foreground dark:bg-green-500/10",
line.kind === "remove" &&
"border-l-2 border-red-500/50 bg-red-500/12 text-foreground dark:bg-red-500/10",
line.kind === "meta" && "text-muted-foreground/70",
)}
>
{line.text || " "}
</span>
);
function toFileContentLine(line: FileEditDiffLine): FileContentLine {
return {
kind: line.kind,
text: line.text,
};
}

View File

@@ -14,13 +14,17 @@ import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Skeleton } from "@/shared/ui/skeleton";
import { Spinner } from "@/shared/ui/spinner";
import { AgentSessionTranscriptList } from "./AgentSessionTranscriptList";
import {
AgentSessionTranscriptList,
type AgentSessionTranscriptEmptyState,
} from "./AgentSessionTranscriptList";
import { RawEventRail } from "./RawEventRail";
import type {
ConnectionState,
ObserverEvent,
TranscriptItem,
} from "./agentSessionTypes";
import type { AgentSessionTranscriptVariant } from "./agentSessionTranscriptContext";
import {
deriveLatestSessionId,
resolveDisplayEvents,
@@ -34,12 +38,17 @@ type ManagedAgentSessionPanelProps = {
agent: Pick<ManagedAgent, "pubkey" | "name" | "status"> & {
avatarUrl?: string | null;
};
autoTail?: boolean;
channelId?: string | null;
className?: string;
emptyDescription?: string;
emptyState?: AgentSessionTranscriptEmptyState;
panelPadding?: boolean;
rawLayout?: "responsive" | "exclusive";
showHeader?: boolean;
showRaw?: boolean;
transcriptContentClassName?: string;
transcriptVariant?: AgentSessionTranscriptVariant;
profiles?: UserProfileLookup;
rawEventsOverride?: ObserverEvent[];
transcriptOverride?: TranscriptItem[];
@@ -47,12 +56,17 @@ type ManagedAgentSessionPanelProps = {
export function ManagedAgentSessionPanel({
agent,
autoTail = false,
channelId = null,
className,
emptyDescription = "Mention this agent in a channel to watch the next turn.",
emptyState = "idle",
panelPadding = true,
rawLayout = "responsive",
showHeader = true,
showRaw = true,
transcriptContentClassName,
transcriptVariant = "default",
profiles,
rawEventsOverride,
transcriptOverride,
@@ -88,7 +102,9 @@ export function ManagedAgentSessionPanel({
return (
<section
className={cn(
"rounded-lg border border-border/70 bg-background/80 p-4 shadow-xs",
"rounded-lg border border-border/70 bg-background/80 shadow-xs",
panelPadding && "p-4",
autoTail && "flex flex-col overflow-hidden",
className,
)}
>
@@ -106,7 +122,10 @@ export function ManagedAgentSessionPanel({
agentName={agent.name}
agentPubkey={agent.pubkey}
connectionState={connectionState}
autoTail={autoTail}
channelId={channelId}
emptyDescription={emptyDescription}
emptyState={emptyState}
errorMessage={errorMessage}
events={displayEvents}
hasObserver={hasObserver}
@@ -115,6 +134,8 @@ export function ManagedAgentSessionPanel({
rawLayout={rawLayout}
showRaw={showRaw}
transcript={displayTranscript}
transcriptContentClassName={transcriptContentClassName}
transcriptVariant={transcriptVariant}
/>
</section>
);
@@ -159,8 +180,11 @@ function SessionBody({
agentAvatarUrl,
agentName,
agentPubkey,
autoTail,
connectionState,
channelId,
emptyDescription,
emptyState,
errorMessage,
events,
hasObserver,
@@ -169,12 +193,17 @@ function SessionBody({
rawLayout,
showRaw,
transcript,
transcriptContentClassName,
transcriptVariant,
}: {
agentAvatarUrl: string | null;
agentName: string;
agentPubkey: string;
autoTail: boolean;
channelId: string | null;
connectionState: ConnectionState;
emptyDescription: string;
emptyState: AgentSessionTranscriptEmptyState;
errorMessage: string | null;
events: ObserverEvent[];
hasObserver: boolean;
@@ -183,6 +212,8 @@ function SessionBody({
rawLayout: "responsive" | "exclusive";
showRaw: boolean;
transcript: TranscriptItem[];
transcriptContentClassName?: string;
transcriptVariant: AgentSessionTranscriptVariant;
}) {
const rawRail = resolveRawRailLayout(showRaw, rawLayout);
@@ -215,15 +246,22 @@ function SessionBody({
rawRail.mode === "side"
? "mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]"
: "mt-0",
autoTail && "min-h-0 flex-1 overflow-hidden",
)}
>
<AgentSessionTranscriptList
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
agentPubkey={agentPubkey}
channelId={channelId}
emptyDescription={emptyDescription}
emptyState={emptyState}
items={transcript}
profiles={profiles}
contentContainerClassName={transcriptContentClassName}
scrollScopeKey={`${agentPubkey}:${channelId ?? "all"}`}
autoTail={autoTail}
variant={transcriptVariant}
/>
{rawRail.mode === "side" ? <RawEventRail events={events} /> : null}
</div>

View File

@@ -47,7 +47,7 @@ export function PromptSectionAccordion({
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2",
!open && "line-clamp-2 wrap-break-word",
)}
>
{section.title}
@@ -55,7 +55,9 @@ export function PromptSectionAccordion({
<div
className={cn(
"mt-1 text-xs leading-5 text-foreground/70",
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
open
? "whitespace-pre-wrap wrap-break-word"
: "line-clamp-2 wrap-break-word",
)}
>
{body.length > 0 ? (

View File

@@ -0,0 +1,74 @@
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/shared/lib/cn";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
const MARKS = ["first", "second", "third"] as const;
const STAGGER_SECONDS = 0.25;
const CYCLE_SECONDS = 1.8;
export function TurnLivenessIndicator({
className,
fuzz = false,
}: {
className?: string;
/** Defaults to false — the indicator stays mounted for whole turns. */
fuzz?: boolean;
}) {
const animationsEnabled = useTranscriptAnimationEnabled();
const shouldReduceMotion = useReducedMotion();
const showStaggeredRow = animationsEnabled && !shouldReduceMotion;
if (!showStaggeredRow) {
return (
<div
aria-label="Agent turn in progress"
className={cn("opacity-25", className)}
data-testid="turn-liveness-indicator"
role="status"
>
<FuzzyLogo
ariaLabel="Agent turn in progress"
className="text-foreground"
fuzz={fuzz}
loop
loopRestSeconds={2}
/>
</div>
);
}
return (
<div
aria-label="Agent turn in progress"
className={cn("flex items-center gap-1.5 opacity-25", className)}
data-testid="turn-liveness-indicator"
role="status"
>
{MARKS.map((mark, index) => (
<motion.div
animate={{
opacity: [0, 1, 1, 0],
y: [4, 0, -1, -4],
}}
key={mark}
transition={{
delay: index * STAGGER_SECONDS,
duration: CYCLE_SECONDS,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
times: [0, 0.3, 0.7, 1],
}}
>
<FuzzyLogo
ariaLabel=""
className="w-5! text-foreground"
fuzz={fuzz}
pulse={false}
/>
</motion.div>
))}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
export type ActivityRowLabelParts = {
verb: string;
@@ -71,7 +72,7 @@ export function ActivityRow({
>
<summary
className={cn(
"inline-flex min-h-6 max-w-full cursor-pointer list-none items-center gap-1.5 text-muted-foreground",
"group/row flex min-h-6 w-full max-w-full cursor-pointer list-none items-center gap-1.5 text-muted-foreground",
openToneScope === "summary"
? "group-open/summary:text-foreground"
: "group-open:text-foreground",
@@ -80,7 +81,7 @@ export function ActivityRow({
{summaryChildren}
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform",
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover/row:text-foreground",
openToneScope === "summary"
? "group-open/summary:rotate-180 group-open/summary:text-foreground"
: "group-open:rotate-180 group-open:text-foreground",
@@ -113,6 +114,9 @@ export function ActivityRowLabel({
stats?: ActivityRowStats | null;
title?: string;
}) {
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
return (
<span
className={cn("inline-flex min-w-0 items-center gap-1.5", className)}
@@ -120,12 +124,13 @@ export function ActivityRowLabel({
>
<span
className={cn(
"shrink-0 text-sm font-semibold text-muted-foreground/50",
"shrink-0 font-semibold text-muted-foreground/50",
isCompactPreview ? "text-xs" : "text-sm",
openToneScope === "none"
? null
: openToneScope === "summary"
? "group-open/summary:text-foreground"
: "group-open:text-foreground",
? "transition-colors group-hover/row:text-foreground group-open/summary:text-foreground"
: "transition-colors group-hover/row:text-foreground group-open:text-foreground",
)}
>
{verb}
@@ -133,12 +138,13 @@ export function ActivityRowLabel({
{object ? (
<span
className={cn(
"min-w-0 truncate text-sm font-normal text-muted-foreground/60",
"min-w-0 truncate font-normal text-muted-foreground/60",
isCompactPreview ? "text-xs" : "text-sm",
openToneScope === "none"
? null
: openToneScope === "summary"
? "group-open/summary:text-foreground"
: "group-open:text-foreground",
? "transition-colors group-hover/row:text-foreground group-open/summary:text-foreground"
: "transition-colors group-hover/row:text-foreground group-open:text-foreground",
)}
>
{object}

View File

@@ -1,17 +1,11 @@
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { TranscriptItem } from "../agentSessionTypes";
import { ToolActivity } from "./ToolActivity";
import { TranscriptTimestamp } from "./TranscriptTimestamp";
import type {
ActivityRenderClassItemProps,
AgentTranscriptIdentityProps,
} from "./types";
import type { ActivityRenderClassItemProps } from "./types";
import { UserMessageBubble } from "./UserMessageBubble";
export function MessageActivity(props: ActivityRenderClassItemProps) {
@@ -22,38 +16,21 @@ export function MessageActivity(props: ActivityRenderClassItemProps) {
return null;
}
return (
<MessageItem
agentAvatarUrl={props.agentAvatarUrl}
agentName={props.agentName}
agentPubkey={props.agentPubkey}
item={props.item}
profiles={props.profiles}
/>
);
return <MessageItem item={props.item} profiles={props.profiles} />;
}
function MessageItem({
agentAvatarUrl,
agentName,
agentPubkey,
item,
profiles,
}: AgentTranscriptIdentityProps & {
}: {
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
}) {
const variant = useAgentSessionTranscriptVariant();
const isCompactPreview = variant === "compactPreview";
const isAssistant = item.role === "assistant";
const text = item.text.trim();
const messageLink = getTranscriptMessageLink(item);
const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null;
const assistantLabel = resolveUserLabel({
pubkey: agentPubkey,
fallbackName: agentName,
profiles,
preferResolvedSelfLabel: true,
});
const assistantAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl;
if (!isAssistant) {
return (
@@ -77,24 +54,18 @@ function MessageItem({
data-testid="transcript-assistant-message"
>
<div className="group relative flex w-full min-w-0 flex-col items-start gap-1">
<div className="mb-0.5 flex items-center gap-1.5 text-xs">
<UserAvatar
avatarUrl={assistantAvatarUrl}
className="shrink-0"
displayName={assistantLabel}
size="xs"
testId="transcript-assistant-avatar"
<div
className={
isCompactPreview
? "w-full min-w-0 text-xs leading-4"
: "w-full min-w-0 text-sm"
}
title={formatTranscriptTimestampTitle(item.timestamp)}
>
<Markdown
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
content={text || " "}
/>
<span className="text-sm font-semibold text-foreground">
{assistantLabel}
</span>
<TranscriptTimestamp
messageLink={messageLink}
timestamp={item.timestamp}
/>
</div>
<div className="w-full min-w-0 text-sm">
<Markdown content={text || " "} tight />
</div>
</div>
</div>

View File

@@ -0,0 +1,24 @@
import { ArrowUpRight } from "lucide-react";
import { cn } from "@/shared/lib/cn";
/**
* Hover affordance for transcript message bubbles that navigate to the
* original message in chat. Rendered inside a `group/bubble` container; fades
* in on hover or keyboard focus of the bubble.
*/
export function MessageLinkHoverCue({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-background/95 py-0.5 pl-1.5 pr-1 text-2xs font-medium text-muted-foreground",
"opacity-0 transition-opacity duration-150 group-hover/bubble:opacity-100 group-focus-visible/bubble:opacity-100",
className,
)}
>
Open in chat
<ArrowUpRight className="h-3 w-3" />
</span>
);
}

View File

@@ -39,8 +39,8 @@ export function PlanActivity(props: ActivityRenderClassItemProps) {
<ActivityRowLabel object="plan" openToneScope="tool" verb="Updated" />
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-5 text-muted-foreground">
<Markdown
className="leading-5"
content={props.item.text.trim() || "No plan details."}
tight
/>
</ActivityRowContent>
</ActivityRow>

View File

@@ -22,8 +22,11 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) {
title={formatTranscriptTimestampTitle(props.item.timestamp)}
>
<ActivityRowLabel openToneScope="tool" verb={props.item.title} />
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-6 text-muted-foreground">
<Markdown compact content={props.item.text.trim() || " "} />
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-5 text-muted-foreground">
<Markdown
className="leading-5"
content={props.item.text.trim() || " "}
/>
</ActivityRowContent>
</ActivityRow>
);

View File

@@ -17,6 +17,9 @@ export const ACTIVITY_RENDER_CLASS_PRESENTERS = {
message: MessageActivity,
"relay-op": ToolActivity,
"file-edit": ToolActivity,
"file-read": ToolActivity,
"skill-read": ToolActivity,
image: ToolActivity,
shell: ToolActivity,
status: LifecycleActivity,
thought: ThoughtActivity,

View File

@@ -1,13 +1,18 @@
import type * as React from "react";
import * as React from "react";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
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 type { TranscriptItem } from "../agentSessionTypes";
import { MessageLinkHoverCue } from "./MessageLinkHoverCue";
import { useTranscriptBubbleOverflow } from "./useTranscriptBubbleOverflow";
export function UserMessageBubble({
bubbleClassName,
@@ -24,7 +29,18 @@ export function UserMessageBubble({
item: Extract<TranscriptItem, { type: "message" }>;
profiles?: UserProfileLookup;
}) {
const variant = useAgentSessionTranscriptVariant();
const { goChannel } = useAppNavigation();
const { openProfilePanel } = useProfilePanel();
const isCompactPreview = variant === "compactPreview";
const shouldClampBubble = !isCompactPreview;
const [bubbleRef, hasBubbleOverflow] =
useTranscriptBubbleOverflow(shouldClampBubble);
const text = item.text.trim();
const messageLink =
shouldClampBubble && item.channelId && item.messageId
? { channelId: item.channelId, messageId: item.messageId }
: null;
const authorProfile = item.authorPubkey
? profiles?.[item.authorPubkey.toLowerCase()]
: null;
@@ -35,36 +51,126 @@ export function UserMessageBubble({
profiles,
})
: item.title || "User";
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 = messageLink
? {
onClick: handleBubbleClick,
onKeyDown: handleBubbleKeyDown,
role: "link" as const,
tabIndex: 0,
}
: {};
return (
<div
className="flex flex-row items-start justify-end animate-in fade-in duration-200 motion-reduce:animate-none"
className={cn(
"flex flex-row items-start animate-in fade-in duration-200 motion-reduce:animate-none",
isCompactPreview ? "justify-start" : "justify-end",
)}
data-role="user-message"
data-testid="transcript-user-message"
>
<UserAvatar
avatarUrl={authorProfile?.avatarUrl ?? null}
className="order-last ml-2 mt-1 shrink-0"
displayName={authorLabel}
size="xs"
/>
{isCompactPreview ? null : item.authorPubkey && openProfilePanel ? (
<button
aria-label={`Open ${authorLabel} profile`}
className="pointer-events-auto order-last ml-2 mt-1 size-7 shrink-0 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (item.authorPubkey) {
openProfilePanel(item.authorPubkey);
}
}}
type="button"
>
<UserAvatar
avatarUrl={authorProfile?.avatarUrl ?? null}
className="size-full text-xs"
displayName={authorLabel}
size="sm"
/>
</button>
) : (
<UserAvatar
avatarUrl={authorProfile?.avatarUrl ?? null}
className="order-last ml-2 mt-1 size-7 shrink-0 text-xs"
displayName={authorLabel}
size="sm"
/>
)}
<div
className={cn(
"group relative flex max-w-[85%] min-w-0 flex-col items-end gap-1",
"group relative flex min-w-0 flex-1 flex-col items-end gap-1",
isCompactPreview && "items-start",
className,
)}
>
<div
className={cn(
"w-full min-w-0 rounded-2xl bg-muted p-3 text-sm leading-relaxed text-foreground",
"w-full min-w-0 rounded-2xl border border-border/70 bg-transparent p-3 text-sm leading-relaxed text-foreground",
shouldClampBubble && "relative max-h-36 overflow-hidden",
messageLink &&
"group/bubble cursor-pointer transition-colors hover:border-border hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isCompactPreview && "p-2 text-xs leading-4",
bubbleClassName,
)}
ref={bubbleRef}
{...bubbleLinkProps}
>
<Markdown content={text || " "} mediaInset tight />
<Markdown
className={isCompactPreview ? "text-xs leading-4" : "leading-5"}
content={text || " "}
mediaInset
/>
{children}
{hasBubbleOverflow ? (
<span className="pointer-events-none absolute inset-x-0 bottom-0 h-8 rounded-b-2xl bg-linear-to-b from-transparent to-background" />
) : null}
{messageLink ? <MessageLinkHoverCue /> : null}
</div>
{footer}
</div>
</div>
);
}
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;
}

View File

@@ -0,0 +1,34 @@
import * as React from "react";
export function useTranscriptBubbleOverflow(enabled: boolean) {
const ref = React.useRef<HTMLDivElement>(null);
const [hasOverflow, setHasOverflow] = React.useState(false);
React.useLayoutEffect(() => {
const element = ref.current;
if (!enabled || !element) {
setHasOverflow(false);
return;
}
const updateOverflow = () => {
setHasOverflow(element.scrollHeight > element.clientHeight + 1);
};
updateOverflow();
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(updateOverflow);
observer.observe(element);
if (element.firstElementChild) {
observer.observe(element.firstElementChild);
}
return () => observer.disconnect();
}, [enabled]);
return [ref, hasOverflow] as const;
}

View File

@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildFileReadContent,
buildSkillReadContent,
} from "./agentSessionFileRead.ts";
const baseDescriptor = {
renderClass: "file-read",
label: "Read file",
preview: "src/App.tsx",
groupKey: "read_file",
};
function makeTool(overrides = {}) {
return {
id: "tool:1",
type: "tool",
title: "read_file",
toolName: "read_file",
buzzToolName: null,
status: "completed",
args: { path: "src/App.tsx" },
result: "",
isError: false,
timestamp: "2026-06-14T19:00:00.000Z",
startedAt: "2026-06-14T19:00:00.000Z",
completedAt: "2026-06-14T19:00:01.000Z",
descriptor: baseDescriptor,
...overrides,
};
}
test("buildFileReadContent returns null for non file-read render class", () => {
assert.equal(
buildFileReadContent(makeTool(), {
...baseDescriptor,
renderClass: "generic",
}),
null,
);
});
test("buildFileReadContent parses range header and meta footer", () => {
const path = "src/App.tsx";
const result = [
`${path} (lines 81-300 of 438)`,
"81:export function App() {",
"82: return null;",
"[showing lines 81-300 of 438; use offset=300 to continue]",
].join("\n");
const content = buildFileReadContent(
makeTool({ args: { path }, result }),
baseDescriptor,
);
assert.ok(content);
assert.equal(content.path, path);
assert.equal(content.footerText, `${path} (lines 81-300 of 438)`);
assert.equal(content.lines.length, 3);
assert.equal(content.lines[0]?.kind, "context");
assert.equal(content.lines[2]?.kind, "meta");
});
test("buildFileReadContent handles empty result text", () => {
assert.equal(
buildFileReadContent(makeTool({ result: " " }), baseDescriptor),
null,
);
});
const skillDescriptor = {
renderClass: "skill-read",
label: "Read skill",
preview: "block-safe-github",
groupKey: "skill:load",
};
test("buildSkillReadContent returns null for non skill-read render class", () => {
assert.equal(
buildSkillReadContent(
makeTool({
toolName: "load_skill",
args: { name: "block-safe-github" },
}),
baseDescriptor,
),
null,
);
});
test("buildSkillReadContent maps skill body into file content panel", () => {
const content = buildSkillReadContent(
makeTool({
toolName: "load_skill",
args: { name: "block-safe-github" },
result:
"# Safe GitHub usage at Block\n\nAll Block code must live in org repos.",
descriptor: skillDescriptor,
}),
skillDescriptor,
);
assert.ok(content);
assert.equal(content.path, "block-safe-github");
assert.equal(content.footerText, "block-safe-github/SKILL.md");
assert.equal(content.lines.length, 3);
assert.equal(content.lines[0]?.text, "# Safe GitHub usage at Block");
assert.equal(
content.lines[2]?.text,
"All Block code must live in org repos.",
);
});
test("buildSkillReadContent uses the supporting-file path in the footer", () => {
const skillRef = "block-safe-github/references/foo.md";
const content = buildSkillReadContent(
makeTool({
toolName: "load_skill",
args: { name: skillRef },
result: "# Reference\n",
descriptor: {
...skillDescriptor,
label: "Read skill file",
preview: skillRef,
groupKey: "skill:load-file",
},
}),
{
...skillDescriptor,
label: "Read skill file",
preview: skillRef,
groupKey: "skill:load-file",
},
);
assert.ok(content);
assert.equal(content.footerText, skillRef);
});

View File

@@ -0,0 +1,156 @@
import type {
AgentActivityDescriptor,
TranscriptItem,
} from "./agentSessionTypes";
import {
asRecord,
getToolString,
parseToolResultValue,
} from "./agentSessionUtils";
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
export type FileReadContentLine = {
kind: "context" | "meta";
text: string;
};
export type FileReadContent = {
footerText: string;
footerTitle: string;
lines: FileReadContentLine[];
path: string;
};
export function buildSkillReadContent(
item: ToolItem,
descriptor: AgentActivityDescriptor,
): FileReadContent | null {
if (descriptor.renderClass !== "skill-read") {
return null;
}
const skillRef =
getToolString(item.args, ["name"]) ??
descriptor.preview ??
descriptor.object;
if (!skillRef) {
return null;
}
const resultText = getResultText(item.result);
if (!resultText.trim()) {
return null;
}
const rawLines = trimTrailingEmptyLines(resultText.split(/\r?\n/));
const lines =
rawLines.length > 0
? rawLines.map((line) => ({
kind: "context" as const,
text: line,
}))
: [{ kind: "meta" as const, text: "No skill content returned." }];
const footerText = skillRef.includes("/") ? skillRef : `${skillRef}/SKILL.md`;
return {
footerText,
footerTitle: skillRef,
lines,
path: skillRef,
};
}
export function buildFileReadContent(
item: ToolItem,
descriptor: AgentActivityDescriptor,
): FileReadContent | null {
if (descriptor.renderClass !== "file-read") {
return null;
}
const path =
getToolString(item.args, ["path", "file", "file_path", "target_file"]) ??
descriptor.object ??
descriptor.preview;
if (!path) {
return null;
}
const resultText = getResultText(item.result);
if (!resultText.trim()) {
return null;
}
const parsed = parseReadFileOutput(resultText, path);
return {
footerText: parsed.footerText,
footerTitle: parsed.footerTitle,
lines: parsed.lines,
path,
};
}
function parseReadFileOutput(resultText: string, path: string) {
const rawLines = trimTrailingEmptyLines(resultText.split(/\r?\n/));
const firstLine = rawLines[0] ?? "";
const remainingLines = rawLines.slice(1);
const hasRangeHeader =
firstLine.startsWith(path) && /\s\(lines \d+-\d+ of \d+\)$/.test(firstLine);
const contentLines = hasRangeHeader ? remainingLines : rawLines;
const lines =
contentLines.length > 0
? contentLines.map((line) => ({
kind: isReadFileMetaLine(line)
? ("meta" as const)
: ("context" as const),
text: line,
}))
: [{ kind: "meta" as const, text: "No file content returned." }];
return {
footerText: hasRangeHeader ? firstLine : path,
footerTitle: hasRangeHeader ? `${path}\n${firstLine}` : path,
lines,
};
}
function getResultText(result: string): string {
const parsed = parseToolResultValue(result);
if (typeof parsed === "string") {
return parsed;
}
const record = asRecord(parsed);
return (
getRecordString(record, ["content", "text", "output", "stdout"]) ?? result
);
}
function getRecordString(
record: Record<string, unknown>,
keys: string[],
): string | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") {
return value;
}
}
return null;
}
function isReadFileMetaLine(line: string) {
return /^\[showing lines \d+-\d+ of \d+; use offset=\d+ to continue\]$/.test(
line,
);
}
function trimTrailingEmptyLines(lines: string[]) {
let end = lines.length;
while (end > 0 && lines[end - 1] === "") {
end -= 1;
}
return end === lines.length ? lines : lines.slice(0, end);
}

View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildImageContent } from "./agentSessionImageContent.ts";
const imageDescriptor = {
renderClass: "image",
label: "Viewed image",
preview: "screenshot.png",
groupKey: "view_image",
};
function makeTool(overrides = {}) {
return {
id: "tool:1",
type: "tool",
title: "view_image",
toolName: "view_image",
buzzToolName: null,
status: "completed",
args: {},
result: "",
isError: false,
timestamp: "2026-06-14T19:00:00.000Z",
startedAt: "2026-06-14T19:00:00.000Z",
completedAt: "2026-06-14T19:00:01.000Z",
descriptor: imageDescriptor,
...overrides,
};
}
test("buildImageContent returns null for non image render class", () => {
assert.equal(
buildImageContent(makeTool(), {
...imageDescriptor,
renderClass: "generic",
}),
null,
);
});
test("buildImageContent accepts http and data image sources", () => {
const http = buildImageContent(
makeTool({
args: { source: "https://example.com/image.png" },
}),
imageDescriptor,
);
assert.deepEqual(http, {
src: "https://example.com/image.png",
title: "screenshot.png",
});
const data = buildImageContent(
makeTool({
args: { source: "data:image/png;base64,abc" },
}),
imageDescriptor,
);
assert.equal(data?.src, "data:image/png;base64,abc");
});
test("buildImageContent rejects local filesystem paths", () => {
assert.equal(
buildImageContent(
makeTool({ args: { source: "desktop/assets/screenshot.png" } }),
imageDescriptor,
),
null,
);
});

View File

@@ -0,0 +1,40 @@
import type {
AgentActivityDescriptor,
TranscriptItem,
} from "./agentSessionTypes";
import { getToolString } from "./agentSessionUtils";
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
export type ImageToolContent = {
src: string;
title: string | null;
};
export function buildImageContent(
item: ToolItem,
descriptor: AgentActivityDescriptor,
): ImageToolContent | null {
if (descriptor.renderClass !== "image") {
return null;
}
const source = getToolString(item.args, ["source"]);
if (!source) {
return null;
}
const trimmed = source.trim();
if (
!trimmed.startsWith("data:image/") &&
!trimmed.startsWith("http://") &&
!trimmed.startsWith("https://")
) {
return null;
}
return {
src: trimmed,
title: descriptor.preview ?? descriptor.object ?? null,
};
}

View File

@@ -53,6 +53,41 @@ test("parseBuzzCliCommand promotes buzz message sends to message descriptors", (
assert.equal(descriptor?.operation, "messages.send");
});
test("classifyTool promotes load_skill to skill-read descriptors", () => {
const descriptor = classifyTool({
title: "load_skill",
toolName: "load_skill",
buzzToolName: null,
args: { name: "block-safe-github" },
result: "# Safe GitHub usage at Block\n",
isError: false,
});
assert.equal(descriptor.renderClass, "skill-read");
assert.equal(descriptor.label, "Read skill");
assert.equal(descriptor.preview, "block-safe-github");
assert.deepEqual(descriptor.action, {
verb: "Read",
object: "block-safe-github",
});
assert.equal(descriptor.groupKey, "skill:load");
});
test("classifyTool promotes supporting-file load_skill to skill-read file descriptors", () => {
const descriptor = classifyTool({
title: "load_skill",
toolName: "load_skill",
buzzToolName: null,
args: { name: "block-safe-github/references/foo.md" },
result: "# Reference\n",
isError: false,
});
assert.equal(descriptor.renderClass, "skill-read");
assert.equal(descriptor.label, "Read skill file");
assert.equal(descriptor.groupKey, "skill:load-file");
});
test("classifyTool promotes buzz CLI shell commands to relay operations", () => {
const descriptor = classifyTool({
title: "Shell",

View File

@@ -87,6 +87,9 @@ const TOOL_CLASS_LABELS: Record<AgentActivityRenderClass, string> = {
message: "Message",
"relay-op": "Buzz relay op",
"file-edit": "File edit",
"file-read": "File read",
"skill-read": "Skill read",
image: "Image",
shell: "Shell command",
status: "Status",
thought: "Thought",
@@ -99,6 +102,7 @@ const TOOL_CLASS_LABELS: Record<AgentActivityRenderClass, string> = {
};
const providers: ToolClassifierProvider[] = [
classifyLoadSkillTool,
classifyDeveloperHarnessTool,
classifyBuzzTool,
];
@@ -139,6 +143,28 @@ export function renderClassLabel(renderClass: AgentActivityRenderClass) {
return TOOL_CLASS_LABELS[renderClass];
}
function classifyLoadSkillTool(
input: ToolClassificationInput,
): AgentActivityDescriptor | null {
const isLoadSkill = [input.toolName, input.title, input.buzzToolName].some(
(value) => value && normalizeToolNameText(value) === "load_skill",
);
if (!isLoadSkill) return null;
const skillRef = getToolString(input.args, ["name"]);
const object = skillRef ?? "skill";
const isSupportingFile = skillRef?.includes("/") ?? false;
return {
renderClass: "skill-read",
label: isSupportingFile ? "Read skill file" : "Read skill",
preview: skillRef,
action: { verb: "Read", object },
source: "harness",
groupKey: isSupportingFile ? "skill:load-file" : "skill:load",
};
}
function classifyDeveloperHarnessTool(
input: ToolClassificationInput,
): AgentActivityDescriptor | null {
@@ -164,7 +190,7 @@ function classifyDeveloperHarnessTool(
if (kind === "read_file") {
const path = getToolString(input.args, ["path"]);
return {
renderClass: "generic",
renderClass: "file-read",
label: "Read file",
preview: path,
action: { verb: "Read", object: path ?? "file" },
@@ -176,7 +202,7 @@ function classifyDeveloperHarnessTool(
if (kind === "view_image") {
const source = getToolString(input.args, ["source"]);
return {
renderClass: "generic",
renderClass: "image",
label: "Viewed image",
preview: source ? basenameOrUrl(source) : null,
action: {

View File

@@ -96,8 +96,10 @@ test("buildCompactToolSummary formats view_image thumbnail source", () => {
}),
);
assert.equal(summary.kind, "image");
assert.equal(summary.label, "Viewed image");
assert.equal(summary.thumbnailSrc, source);
assert.equal(summary.imageContent?.src, source);
assert.equal(summary.preview, source);
});
@@ -109,24 +111,53 @@ test("buildCompactToolSummary uses basename for local view_image paths", () => {
}),
);
assert.equal(summary.kind, "image");
assert.equal(summary.thumbnailSrc, null);
assert.equal(summary.imageContent, null);
assert.equal(summary.preview, "screenshot.png");
});
test("buildCompactToolSummary formats read_file path preview", () => {
const path = "desktop/src/app/App.tsx";
const summary = buildCompactToolSummary(
makeTool({
toolName: "read_file",
args: { path: "desktop/src/app/App.tsx" },
args: { path },
result: `${path} (lines 1-2 of 2)\n1:export {}\n2: `,
}),
);
assert.equal(summary.kind, "file-read");
assert.equal(summary.label, "Read file");
assert.equal(summary.preview, "desktop/src/app/App.tsx");
assert.equal(summary.preview, path);
assert.deepEqual(summary.action, {
verb: "Read",
object: "desktop/src/app/App.tsx",
object: path,
});
assert.ok(summary.fileReadContent);
});
test("buildCompactToolSummary formats load_skill into skill-read file panel", () => {
const summary = buildCompactToolSummary(
makeTool({
toolName: "load_skill",
args: { name: "block-safe-github" },
result: "# Safe GitHub usage at Block\n",
}),
);
assert.equal(summary.kind, "skill-read");
assert.equal(summary.label, "Read skill");
assert.equal(summary.preview, "block-safe-github");
assert.deepEqual(summary.action, {
verb: "Read",
object: "block-safe-github",
});
assert.ok(summary.fileReadContent);
assert.equal(
summary.fileReadContent?.footerText,
"block-safe-github/SKILL.md",
);
});
test("buildCompactToolSummary formats todo list preview", () => {
@@ -176,6 +207,29 @@ test("buildCompactToolSummary promotes non-send buzz CLI commands to relay ops",
assert.equal(summary.preview, "channel-1");
assert.deepEqual(summary.action, { verb: "Read", object: "channel-1" });
assert.equal(summary.presentation, "inline");
assert.equal(summary.shellContent, "buzz channels get --channel channel-1");
});
test("buildCompactToolSummary exposes shellContent for shell-sourced buzz CLI reads", () => {
const command =
"sleep 45; buzz messages thread --channel channel-uuid --event abc | tail -n 20";
const summary = buildCompactToolSummary(
makeTool({
toolName: "shell",
args: { command },
result: JSON.stringify({
stdout: "[1782969453] user: hello",
exit_code: 0,
}),
}),
);
assert.equal(summary.kind, "relay-op");
assert.equal(summary.shellContent, command);
assert.deepEqual(summary.action, {
verb: "Read",
object: "channel-uuid",
});
});
test("buildCompactToolSummary derives structured actions for native Buzz MCP tools", () => {

View File

@@ -11,11 +11,23 @@ import {
type FileEditDiff,
type FileEditDiffSummary,
} from "./agentSessionFileEditDiff";
import {
buildFileReadContent,
buildSkillReadContent,
type FileReadContent,
} from "./agentSessionFileRead";
import {
buildImageContent,
type ImageToolContent,
} from "./agentSessionImageContent";
export type CompactToolKind =
| "message"
| "relay-op"
| "file-edit"
| "file-read"
| "skill-read"
| "image"
| "shell"
| "status"
| "thought"
@@ -33,6 +45,9 @@ export type CompactToolSummary = {
preview: string | null;
fileEditSummary: FileEditDiffSummary | null;
fileEditDiff: FileEditDiff | null;
fileReadContent: FileReadContent | null;
imageContent: ImageToolContent | null;
shellContent: string | null;
/** When set, the compact row renders a tiny image instead of text preview. */
thumbnailSrc: string | null;
presentation: "inline" | "message";
@@ -55,7 +70,12 @@ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary {
deletions: fileEditDiff.deletions,
}
: null;
const thumbnailSrc = getThumbnailSrc(item, descriptor);
const fileReadContent =
buildFileReadContent(item, descriptor) ??
buildSkillReadContent(item, descriptor);
const imageContent = buildImageContent(item, descriptor);
const shellContent = buildShellContent(item, descriptor);
const thumbnailSrc = imageContent?.src ?? null;
const failed = item.isError || item.status === "failed";
const running = item.status === "executing" || item.status === "pending";
return {
@@ -65,6 +85,9 @@ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary {
preview: fileEditSummary?.filename ?? descriptor.preview,
fileEditSummary,
fileEditDiff,
fileReadContent,
imageContent,
shellContent,
thumbnailSrc,
presentation: descriptor.renderClass === "message" ? "message" : "inline",
descriptor,
@@ -91,22 +114,18 @@ function labelForStatus(
return label;
}
function getThumbnailSrc(
function buildShellContent(
item: ToolItem,
descriptor: AgentActivityDescriptor,
): string | null {
const operation =
descriptor.operation ?? descriptor.groupKey ?? item.toolName;
if (!operation.includes("view_image") && item.toolName !== "view_image") {
const command = getToolString(item.args, ["command"]);
if (!command) {
return null;
}
const source = getToolString(item.args, ["source"]);
if (!source) return null;
const trimmed = source.trim();
return trimmed.startsWith("data:image/") ||
trimmed.startsWith("http://") ||
trimmed.startsWith("https://")
? trimmed
: null;
if (descriptor.renderClass === "shell" || descriptor.source === "shell") {
return command;
}
return null;
}

View File

@@ -0,0 +1,13 @@
import * as React from "react";
export type AgentSessionTranscriptVariant = "default" | "compactPreview";
const AgentSessionTranscriptVariantContext =
React.createContext<AgentSessionTranscriptVariant>("default");
export const AgentSessionTranscriptVariantProvider =
AgentSessionTranscriptVariantContext.Provider;
export function useAgentSessionTranscriptVariant() {
return React.useContext(AgentSessionTranscriptVariantContext);
}

View File

@@ -216,9 +216,9 @@ test("buildTranscriptDisplayBlocks groups same-kind tool runs within a turn", ()
const items = [1, 2, 3].map((index) => ({
id: `tool:${index}`,
type: "tool",
renderClass: "generic",
renderClass: "file-read",
descriptor: {
renderClass: "generic",
renderClass: "file-read",
label: "Read file",
preview: `file-${index}.ts`,
groupKey: "read_file",
@@ -313,10 +313,10 @@ test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded"
});
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "generic", "read_file"),
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
mkTool("read-2", "Read file", "generic", "read_file"),
mkTool("read-3", "Read file", "generic", "read_file"),
mkTool("read-2", "Read file", "file-read", "read_file"),
mkTool("read-3", "Read file", "file-read", "read_file"),
]);
assert.equal(block.kind, "turn");

View File

@@ -167,8 +167,11 @@ function sameKindLabel(item: TranscriptItem, count: number): string {
if (renderClass === "file-edit") {
return `Edited ${count} file${count === 1 ? "" : "s"}`;
}
if (label === "Read file") return `Read ${count} files`;
if (label === "Ran command") return `Ran ${count} commands`;
if (renderClass === "file-read") return `Read ${count} files`;
if (renderClass === "skill-read") {
return `Read ${count} skill${count === 1 ? "" : "s"}`;
}
if (renderClass === "shell") return `Ran ${count} commands`;
if (renderClass === "relay-op") return `Ran ${count} Buzz relay ops`;
return `${label} ×${count}`;
}

View File

@@ -24,6 +24,9 @@ export type AgentActivityRenderClass =
| "message"
| "relay-op"
| "file-edit"
| "file-read"
| "skill-read"
| "image"
| "shell"
| "status"
| "thought"

View File

@@ -1,3 +1,5 @@
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
export function getToolString(
record: Record<string, unknown>,
keys: string[],
@@ -113,6 +115,11 @@ export function isInlineImageData(source: string): boolean {
return source.startsWith("data:image/");
}
/** Resolve a tool image source for display (inline data URIs or relay URLs). */
export function resolveToolImageSrc(source: string): string {
return isInlineImageData(source) ? source : rewriteRelayUrl(source);
}
function getToolNumber(
record: Record<string, unknown>,
keys: string[],

View File

@@ -0,0 +1,61 @@
import * as React from "react";
/**
* User preference for animating transcript activity rows as they stream in.
* Persisted in localStorage and shared across every transcript surface
* (thread panel, profile live-activity preview). This is a device-level UI
* preference, not workspace-scoped data, so it is intentionally not reset on
* workspace switch.
*/
const STORAGE_KEY = "buzz:animate-transcript-activity";
const listeners = new Set<() => void>();
let animationEnabled = readStoredPreference();
function readStoredPreference(): boolean {
if (typeof window === "undefined") {
return true;
}
try {
return window.localStorage.getItem(STORAGE_KEY) !== "0";
} catch {
return true;
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot(): boolean {
return animationEnabled;
}
function getServerSnapshot(): boolean {
return true;
}
/** Update the preference and notify all subscribed components. */
export function setTranscriptAnimationEnabled(enabled: boolean): void {
animationEnabled = enabled;
try {
window.localStorage.setItem(STORAGE_KEY, enabled ? "1" : "0");
} catch {
// Persistence is best-effort; the in-memory value still applies.
}
for (const listener of listeners) {
listener();
}
}
/** Whether transcript activity rows should animate in. */
export function useTranscriptAnimationEnabled(): boolean {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

View File

@@ -1,37 +1,52 @@
import * as React from "react";
import { Octagon, Settings, TerminalSquare } from "lucide-react";
import { Octagon, Settings, Sparkles, TerminalSquare } from "lucide-react";
import { toast } from "sonner";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { scopeByChannel } from "@/features/agents/ui/agentSessionPanelLayout";
import type {
ObserverEvent,
TranscriptItem,
} from "@/features/agents/ui/agentSessionTypes";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
import {
useAgentTranscript,
useObserverEvents,
} from "@/features/agents/ui/useObserverEvents";
import { cancelManagedAgentTurn } from "@/shared/api/agentControl";
import type { Channel } from "@/shared/api/types";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { useStickToBottom } from "@/shared/hooks/useStickToBottom";
import { useNow } from "@/shared/lib/useNow";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import {
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderActions,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
AuxiliaryPanelHeaderTitleBlock,
} from "@/shared/layout/AuxiliaryPanel";
import { Button } from "@/shared/ui/button";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Switch } from "@/shared/ui/switch";
import {
setTranscriptAnimationEnabled,
useTranscriptAnimationEnabled,
} from "@/features/agents/ui/transcriptAnimationPreference";
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
type AgentSessionThreadPanelProps = {
agent: ChannelAgentSessionAgent;
channel: Channel | null;
channelId?: string | null;
canInterruptTurn: boolean;
isWorking: boolean;
layout?: "standalone" | "split";
@@ -47,6 +62,7 @@ export function AgentSessionThreadPanel({
agent,
canInterruptTurn,
channel,
channelId = null,
isWorking,
layout = "standalone",
isSinglePanelView = false,
@@ -62,7 +78,32 @@ export function AgentSessionThreadPanel({
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
const rawFeedScopeKey = `${agent.pubkey}:${channel?.id ?? "all"}`;
const sessionChannelId = channelId ?? channel?.id ?? null;
const now = useNow(1000);
const { events } = useObserverEvents(isLive, agent.pubkey);
const transcript = useAgentTranscript(isLive, agent.pubkey);
const scopedEvents = React.useMemo(
() => scopeByChannel(events, sessionChannelId),
[events, sessionChannelId],
);
const scopedTranscript = React.useMemo(
() => scopeByChannel(transcript, sessionChannelId),
[sessionChannelId, transcript],
);
const latestActivityAt = React.useMemo(
() =>
getLatestActivityTimestamp({
events: scopedEvents,
transcript: scopedTranscript,
}),
[scopedEvents, scopedTranscript],
);
const lastUpdatedLabel = formatLastUpdatedLabel(latestActivityAt, now);
const lastUpdatedTitle =
latestActivityAt === null
? undefined
: `Last updated ${new Date(latestActivityAt).toLocaleString()}`;
const rawFeedScopeKey = `${agent.pubkey}:${sessionChannelId ?? "all"}`;
const [rawFeedState, setRawFeedState] = React.useState(() => ({
scopeKey: rawFeedScopeKey,
show: false,
@@ -75,6 +116,7 @@ export function AgentSessionThreadPanel({
},
[rawFeedScopeKey],
);
const animateActivity = useTranscriptAnimationEnabled();
async function handleInterruptTurn() {
if (!channel) {
return;
@@ -123,11 +165,13 @@ export function AgentSessionThreadPanel({
className="min-w-56"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuCheckboxItem
checked={showRawFeed}
<DropdownMenuItem
className="items-start gap-3"
data-testid="agent-session-toggle-raw-feed"
onCheckedChange={handleRawFeedChange}
onSelect={(event) => {
event.preventDefault();
handleRawFeedChange(!showRawFeed);
}}
title={
showRawFeed
? "Hide raw JSON-RPC payloads."
@@ -145,7 +189,45 @@ export function AgentSessionThreadPanel({
Show raw JSON-RPC activity.
</span>
</span>
</DropdownMenuCheckboxItem>
<Switch
aria-hidden="true"
checked={showRawFeed}
className="pointer-events-none mt-0.5"
tabIndex={-1}
/>
</DropdownMenuItem>
<DropdownMenuItem
className="items-start gap-3"
data-testid="agent-session-toggle-animate-activity"
disabled={showRawFeed}
onSelect={(event) => {
event.preventDefault();
setTranscriptAnimationEnabled(!animateActivity);
}}
title={
showRawFeed
? "Raw activity rows don't animate in."
: animateActivity
? "Stop animating new activity rows."
: "Animate new activity rows as they arrive."
}
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="h-4 w-4 text-muted-foreground" />
Show Animations
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
Slide new activity rows in as they arrive.
</span>
</span>
<Switch
aria-hidden="true"
checked={animateActivity && !showRawFeed}
className="pointer-events-none mt-0.5"
tabIndex={-1}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="items-start gap-3"
@@ -185,13 +267,16 @@ export function AgentSessionThreadPanel({
const agentHeaderContent = (
<>
<AuxiliaryPanelHeaderGroup
align="start"
backButtonAriaLabel="Back from activity"
backButtonTestId="agent-session-back"
onBack={onBackToProfile}
>
<AuxiliaryPanelTitle>
{showRawFeed ? "Raw ACP Activity" : "Activity"}
</AuxiliaryPanelTitle>
<AuxiliaryPanelHeaderTitleBlock
subtitle={lastUpdatedLabel}
subtitleTitle={lastUpdatedTitle}
title={showRawFeed ? "Raw ACP Activity" : "Activity"}
/>
</AuxiliaryPanelHeaderGroup>
{agentHeaderActions}
</>
@@ -223,10 +308,10 @@ export function AgentSessionThreadPanel({
>
<ManagedAgentSessionPanel
agent={agent}
channelId={channel?.id ?? null}
channelId={sessionChannelId}
className="border-0 bg-transparent p-0 shadow-none"
emptyDescription={
channel
sessionChannelId
? `Mention ${agent.name} in the channel to see its work here.`
: `Mention ${agent.name} in any channel to see its work here.`
}
@@ -239,3 +324,69 @@ export function AgentSessionThreadPanel({
</AuxiliaryPanel>
);
}
function getLatestActivityTimestamp({
events,
transcript,
}: {
events: readonly ObserverEvent[];
transcript: readonly TranscriptItem[];
}): number | null {
let latest: number | null = null;
const record = (timestamp: string) => {
const parsed = Date.parse(timestamp);
if (!Number.isFinite(parsed)) {
return;
}
if (latest === null || parsed > latest) {
latest = parsed;
}
};
for (const event of events) {
record(event.timestamp);
}
for (const item of transcript) {
record(item.timestamp);
}
return latest;
}
function formatLastUpdatedLabel(timestamp: number | null, now: number): string {
if (timestamp === null) {
return "No updates yet";
}
return `Last updated ${formatRelativeActivityTime(timestamp, now)}`;
}
function formatRelativeActivityTime(timestamp: number, now: number): string {
const elapsedMs = Math.max(0, now - timestamp);
const totalSeconds = Math.floor(elapsedMs / 1_000);
if (totalSeconds < 60) {
return "just now";
}
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) {
return `${totalMinutes}m ago`;
}
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours < 24) {
return `${totalHours}h ago`;
}
const totalDays = Math.floor(totalHours / 24);
if (totalDays < 7) {
return `${totalDays}d ago`;
}
const totalWeeks = Math.floor(totalDays / 7);
return `${totalWeeks}w ago`;
}

View File

@@ -19,7 +19,7 @@ export type BotActivityAgent = Pick<ManagedAgent, "pubkey" | "name">;
type BotActivityBarProps = {
agents: BotActivityAgent[];
channelId?: string | null;
onOpenAgentSession: (pubkey: string) => void;
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
openAgentSessionPubkey: string | null;
profiles?: UserProfileLookup;
typingBotPubkeys: string[];
@@ -244,7 +244,7 @@ export function BotActivityComposerAction({
onClick={() => {
clearHoverTimer();
setOpen(false);
onOpenAgentSession(agent.pubkey);
onOpenAgentSession(agent.pubkey, channelId);
}}
type="button"
>

View File

@@ -118,6 +118,7 @@ export const ChannelPane = React.memo(function ChannelPane({
profiles,
openThreadHeadId,
shouldShowThreadSkeleton,
openAgentSessionChannelId,
openAgentSessionPubkey,
onProfilePanelViewChange,
onProfilePanelTabChange,
@@ -824,13 +825,18 @@ export const ChannelPane = React.memo(function ChannelPane({
agent={selectedAgent}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={
agentSessionSelection.isAgentInActivityList({
activityAgents,
selectedAgent,
})
? activeChannel
: null
openAgentSessionChannelId
? activeChannel?.id === openAgentSessionChannelId
? activeChannel
: null
: agentSessionSelection.isAgentInActivityList({
activityAgents,
selectedAgent,
})
? activeChannel
: null
}
channelId={openAgentSessionChannelId}
isWorking={botTypingEntries.some(
(entry) =>
entry.pubkey.toLowerCase() ===

View File

@@ -56,7 +56,7 @@ export type ChannelPaneProps = {
onMarkRead?: (message: TimelineMessage) => void;
onExpandThreadReplies: (message: TimelineMessage) => void;
onJoinChannel?: () => Promise<void>;
onOpenAgentSession: (pubkey: string) => void;
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
onOpenMembers?: () => void;
onOpenProfilePanel: (pubkey: string) => void;
@@ -100,6 +100,7 @@ export type ChannelPaneProps = {
profiles?: UserProfileLookup;
openThreadHeadId: string | null;
shouldShowThreadSkeleton: boolean;
openAgentSessionChannelId: string | null;
openAgentSessionPubkey: string | null;
onProfilePanelViewChange: (
view: ProfilePanelView,

View File

@@ -108,12 +108,14 @@ export function ChannelScreen({
const {
channelManagementOpen,
clearMessageRouteTarget,
openAgentSessionChannelId,
openAgentSessionPubkey,
openThreadHeadId,
profilePanelPubkey,
profilePanelTab,
profilePanelView,
setChannelManagementOpen,
setOpenAgentSessionChannelId,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelTab,
@@ -598,6 +600,7 @@ export function ChannelScreen({
profilePanelPubkey,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenAgentSessionChannelId,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -938,6 +941,7 @@ export function ChannelScreen({
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
openAgentSessionChannelId={openAgentSessionChannelId}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={effectiveOpenThreadHeadId}
shouldShowThreadSkeleton={shouldShowThreadSkeleton}

View File

@@ -31,6 +31,7 @@ type UseChannelAgentSessionsOptions = {
profilePanelPubkey?: string | null;
setChannelManagementOpen: (open: boolean) => void;
setExpandedThreadReplyIds: (value: Set<string>) => void;
setOpenAgentSessionChannelId: PanelValueSetter;
setOpenAgentSessionPubkey: PanelValueSetter;
setOpenThreadHeadId: (value: string | null) => void;
setProfilePanelPubkey: (value: string | null) => void;
@@ -164,6 +165,7 @@ export function useChannelAgentSessions({
profilePanelPubkey = null,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenAgentSessionChannelId,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -187,17 +189,19 @@ export function useChannelAgentSessions({
}, [setOpenAgentSessionPubkey]);
const openAgentSession = React.useCallback(
(pubkey: string) => {
(pubkey: string, channelId?: string | null) => {
setOpenThreadHeadId(null);
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
setChannelManagementOpen(false);
setOpenAgentSessionPubkey(pubkey);
setOpenAgentSessionChannelId(channelId ?? null);
},
[
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenAgentSessionChannelId,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setThreadReplyTargetId,
@@ -206,10 +210,11 @@ export function useChannelAgentSessions({
);
const selectAgentSession = React.useCallback(
(pubkey: string) => {
(pubkey: string, channelId?: string | null) => {
setOpenAgentSessionPubkey(pubkey);
setOpenAgentSessionChannelId(channelId ?? null);
},
[setOpenAgentSessionPubkey],
[setOpenAgentSessionChannelId, setOpenAgentSessionPubkey],
);
const openThreadAndCloseAgentSession = React.useCallback(

View File

@@ -18,7 +18,8 @@ import {
*
* Params: `thread` (open thread head id), `profile` (profile panel pubkey),
* `profileView` (profile panel focused view), `profileTab` (profile summary
* tab), `agentSession` (agent session panel pubkey), `channelManagement`
* tab), `agentSession` (agent session panel pubkey), `agentSessionChannel`
* (optional channel scope for the agent session panel), `channelManagement`
* (presence flag for the channel-management panel — open/closed only, so it
* carries a sentinel `"1"` rather than an id).
*/
@@ -32,6 +33,7 @@ export type PanelValueSetter = (
const CHANNEL_SEARCH_KEYS = [
"agentSession",
"agentSessionChannel",
"channelManagement",
"messageId",
"profile",
@@ -75,7 +77,16 @@ export function useChannelPanelHistoryState() {
);
const setOpenAgentSessionPubkey = React.useCallback<PanelValueSetter>(
(value, options) => applyPatch({ agentSession: value }, options),
(value, options) =>
applyPatch(
{ agentSession: value, agentSessionChannel: value ? undefined : null },
options,
),
[applyPatch],
);
const setOpenAgentSessionChannelId = React.useCallback<PanelValueSetter>(
(value, options) => applyPatch({ agentSessionChannel: value }, options),
[applyPatch],
);
@@ -97,12 +108,14 @@ export function useChannelPanelHistoryState() {
return {
channelManagementOpen: values.channelManagement != null,
clearMessageRouteTarget,
openAgentSessionChannelId: values.agentSessionChannel,
openAgentSessionPubkey: values.agentSession,
openThreadHeadId: values.thread,
profilePanelPubkey: values.profile,
profilePanelTab: profilePanelTabFromSearch(values.profileTab),
profilePanelView: profilePanelViewFromSearch(values.profileView),
setChannelManagementOpen,
setOpenAgentSessionChannelId,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelTab,

View File

@@ -1,6 +1,5 @@
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { FileText, HatGlasses, Play, X } from "lucide-react";
import type { BlobDescriptor } from "@/shared/api/tauri";
@@ -10,7 +9,7 @@ import {
type UploadingAttachmentPreview,
} from "@/features/messages/lib/useMediaUpload";
import { cn } from "@/shared/lib/cn";
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
import { SimpleImageLightbox } from "@/shared/ui/SimpleImageLightbox";
import { Progress } from "@/shared/ui/progress";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -146,103 +145,28 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="group relative"
>
<div
className="relative h-[55px] max-w-[55px]"
style={mediaStyle}
>
<DialogPrimitive.Root>
<DialogPrimitive.Trigger asChild>
<div className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70">
{isVideo ? (
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
{videoPosterUrl ? (
<img
src={videoPosterUrl}
alt={`Video attachment ${hash}`}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-muted/80" />
)}
<div className="absolute inset-0 bg-black/15" />
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
<Play className="h-4 w-4 fill-white text-white" />
</div>
</div>
) : (
<img
src={thumbUrl}
alt={`Attachment ${hash}`}
className="h-full w-full object-cover"
/>
)}
{isSpoilered ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
data-composer-media-spoiler=""
>
<HatGlasses className="h-4 w-4" />
</div>
) : null}
</div>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
MODAL_BACKDROP_BLUR_CLASS,
)}
/>
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">
Attachment {hash} preview
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size attachment preview. Press Escape or click
outside to close.
</DialogPrimitive.Description>
<DialogPrimitive.Close
className="absolute inset-0 cursor-default"
aria-label="Close lightbox"
/>
{isVideo ? (
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
<video
src={rewriteRelayUrl(attachment.url)}
controls
className="relative max-h-[90vh] max-w-[90vw] rounded-lg"
/>
) : (
<img
alt={`Attachment ${hash}`}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={rewriteRelayUrl(attachment.url)}
/>
)}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onRemove(attachment.url)}
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
>
<X className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Remove attachment</TooltipContent>
</Tooltip>
</div>
<AttachmentMediaLightbox
alt={`Attachment ${hash} preview`}
hash={hash}
isSpoilered={isSpoilered}
isVideo={isVideo}
mediaStyle={mediaStyle}
thumbUrl={thumbUrl}
url={attachment.url}
videoPosterUrl={videoPosterUrl ?? null}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onRemove(attachment.url)}
className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-foreground text-background group-hover:flex"
>
<X className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent>Remove attachment</TooltipContent>
</Tooltip>
</motion.div>
);
})}
@@ -308,3 +232,83 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({
</LayoutGroup>
);
});
function AttachmentMediaLightbox({
alt,
hash,
isSpoilered,
isVideo,
mediaStyle,
thumbUrl,
url,
videoPosterUrl,
}: {
alt: string;
hash: string;
isSpoilered: boolean;
isVideo: boolean;
mediaStyle: React.CSSProperties;
thumbUrl: string;
url: string;
videoPosterUrl: string | null;
}) {
const [lightboxOpen, setLightboxOpen] = React.useState(false);
const previewSrc = rewriteRelayUrl(url);
return (
<div className="relative h-[55px] max-w-[55px]" style={mediaStyle}>
<button
className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70"
onClick={() => setLightboxOpen(true)}
type="button"
>
{isVideo ? (
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
{videoPosterUrl ? (
<img
alt={`Video attachment ${hash}`}
className="h-full w-full object-cover"
src={videoPosterUrl}
/>
) : (
<div className="h-full w-full bg-muted/80" />
)}
<div className="absolute inset-0 bg-black/15" />
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
<Play className="h-4 w-4 fill-white text-white" />
</div>
</div>
) : (
<img
alt={`Attachment ${hash}`}
className="h-full w-full object-cover"
src={thumbUrl}
/>
)}
{isSpoilered ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
data-composer-media-spoiler=""
>
<HatGlasses className="h-4 w-4" />
</div>
) : null}
</button>
<SimpleImageLightbox
alt={alt}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
src={previewSrc}
>
{isVideo ? (
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
<video
className="relative max-h-[90vh] max-w-[90vw] rounded-lg"
controls
src={previewSrc}
/>
) : undefined}
</SimpleImageLightbox>
</div>
);
}

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { classifyTimelineMessageDelta } from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";
/**
* Distance (in CSS pixels) below which we consider the scroll position
@@ -45,7 +44,7 @@ type UseAnchoredScrollOptions = {
isLoading: boolean;
/** Source of truth for the rendered list. Used to detect new-at-bottom
* arrivals and to seed/refresh the anchor pre-render. */
messages: TimelineMessage[];
messages: Array<{ id: string }>;
/** When set, scroll to and highlight this message on mount and on change. */
targetMessageId?: string | null;
@@ -166,7 +165,7 @@ export function useAnchoredScroll({
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
const prevMessageCountRef = React.useRef(0);
const prevMessagesRef = React.useRef<TimelineMessage[]>([]);
const prevMessagesRef = React.useRef<Array<{ id: string }>>([]);
const handledTargetIdRef = React.useRef<string | null>(null);
const highlightTimeoutRef = React.useRef<number | null>(null);
// Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled

View File

@@ -0,0 +1,44 @@
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
export type ProfileActivityAgent = Pick<
ManagedAgent,
"pubkey" | "name" | "status"
> & {
avatarUrl?: string | null;
};
export function resolveProfileActivityAgent({
effectivePubkey,
isBot,
managedAgent,
profile,
relayAgent,
viewerIsOwner,
}: {
effectivePubkey: string | null;
isBot: boolean;
managedAgent: ManagedAgent | undefined;
profile: { avatarUrl?: string | null; displayName?: string | null } | null;
relayAgent: RelayAgent | undefined;
viewerIsOwner: boolean;
}): ProfileActivityAgent | null {
if (managedAgent) {
return {
avatarUrl: managedAgent.avatarUrl,
name: managedAgent.name,
pubkey: managedAgent.pubkey,
status: managedAgent.status,
};
}
if (!viewerIsOwner || !effectivePubkey || !isBot) {
return null;
}
return {
avatarUrl: profile?.avatarUrl ?? null,
name: relayAgent?.name ?? profile?.displayName?.trim() ?? "Agent",
pubkey: effectivePubkey,
status: relayAgent?.status === "offline" ? "stopped" : "deployed",
};
}

View File

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveActivityChannelId } from "./profileActivityCarousel.ts";
test("preserves the selected channel when slides reorder", () => {
assert.equal(
resolveActivityChannelId(
["engineering", "general"],
"general",
"engineering",
),
"general",
);
});
test("falls back to the preferred channel when the selection disappears", () => {
assert.equal(
resolveActivityChannelId(["recent", "general"], "live", "general"),
"general",
);
});
test("falls back to the first slide when neither selection is available", () => {
assert.equal(
resolveActivityChannelId(["recent"], "live", "general"),
"recent",
);
assert.equal(resolveActivityChannelId([], "live", "general"), null);
});

View File

@@ -0,0 +1,13 @@
export function resolveActivityChannelId(
slides: string[],
selectedChannelId: string | null,
preferredChannelId: string | null,
): string | null {
if (selectedChannelId && slides.includes(selectedChannelId)) {
return selectedChannelId;
}
if (preferredChannelId && slides.includes(preferredChannelId)) {
return preferredChannelId;
}
return slides[0] ?? null;
}

View File

@@ -0,0 +1,267 @@
import * as React from "react";
import type { ActiveTurnSummary } from "@/features/agents/activeAgentTurnsStore";
import { subscribeActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import {
getAgentObserverSnapshot,
getAgentTranscript,
subscribeAgentObserverStore,
} from "@/features/agents/observerRelayStore";
import type {
ObserverEvent,
TranscriptItem,
} from "@/features/agents/ui/agentSessionTypes";
import type { ProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent";
import { normalizePubkey } from "@/shared/lib/pubkey";
export type ProfileActivityFeedScope = {
/** Distinct channel ids to surface in the embed switcher. */
channelIds: string[];
/** Whether the observer feed has any events or transcript for this agent. */
hasFeedContent: boolean;
/** True while the active-turn store reports live work for this agent. */
isLive: boolean;
/** Latest observed activity timestamp, keyed by channel id. */
latestActivityAtByChannel: Record<string, number>;
/** Preferred channel scope when no explicit selection exists yet. */
preferredChannelId: string | null;
};
const cachedScopes = new Map<string, ProfileActivityFeedScope>();
function channelIdsEqual(
left: readonly string[],
right: readonly string[],
): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function scopesEqual(
left: ProfileActivityFeedScope,
right: ProfileActivityFeedScope,
): boolean {
return (
left.hasFeedContent === right.hasFeedContent &&
left.isLive === right.isLive &&
left.preferredChannelId === right.preferredChannelId &&
latestActivityByChannelEqual(
left.latestActivityAtByChannel,
right.latestActivityAtByChannel,
) &&
channelIdsEqual(left.channelIds, right.channelIds)
);
}
function latestActivityByChannelEqual(
left: Record<string, number>,
right: Record<string, number>,
): boolean {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (const key of leftKeys) {
if (left[key] !== right[key]) {
return false;
}
}
return true;
}
function stableFeedScope(
cacheKey: string,
next: ProfileActivityFeedScope,
): ProfileActivityFeedScope {
const cached = cachedScopes.get(cacheKey);
if (cached && scopesEqual(cached, next)) {
return cached;
}
cachedScopes.set(cacheKey, next);
return next;
}
function collectChannelIdsFromFeed(
events: readonly ObserverEvent[],
transcript: readonly TranscriptItem[],
): string[] {
const channelIds = new Set<string>();
for (const event of events) {
if (event.channelId) {
channelIds.add(event.channelId);
}
}
for (const item of transcript) {
if (item.channelId) {
channelIds.add(item.channelId);
}
}
return [...channelIds].sort((left, right) => left.localeCompare(right));
}
function deriveLatestChannelId(
events: readonly ObserverEvent[],
transcript: readonly TranscriptItem[],
): string | null {
for (let index = transcript.length - 1; index >= 0; index -= 1) {
const channelId = transcript[index]?.channelId;
if (channelId) {
return channelId;
}
}
for (let index = events.length - 1; index >= 0; index -= 1) {
const channelId = events[index]?.channelId;
if (channelId) {
return channelId;
}
}
return null;
}
function parseTimestampMillis(timestamp: string): number | null {
const millis = Date.parse(timestamp);
return Number.isNaN(millis) ? null : millis;
}
function collectLatestActivityAtByChannel({
activeTurns,
events,
transcript,
}: {
activeTurns: readonly ActiveTurnSummary[];
events: readonly ObserverEvent[];
transcript: readonly TranscriptItem[];
}): Record<string, number> {
const latestActivityAtByChannel: Record<string, number> = {};
const record = (channelId: string | null | undefined, timestamp: number) => {
if (!channelId) {
return;
}
const previous = latestActivityAtByChannel[channelId];
if (previous === undefined || timestamp > previous) {
latestActivityAtByChannel[channelId] = timestamp;
}
};
for (const turn of activeTurns) {
record(turn.channelId, turn.anchorAt);
}
for (const event of events) {
const timestamp = parseTimestampMillis(event.timestamp);
if (timestamp !== null) {
record(event.channelId, timestamp);
}
}
for (const item of transcript) {
const timestamp = parseTimestampMillis(item.timestamp);
if (timestamp !== null) {
record(item.channelId, timestamp);
}
}
return latestActivityAtByChannel;
}
export function deriveProfileActivityFeedScope({
activeTurns,
events,
transcript,
}: {
activeTurns: readonly ActiveTurnSummary[];
events: readonly ObserverEvent[];
transcript: readonly TranscriptItem[];
}): ProfileActivityFeedScope {
const hasFeedContent = events.length > 0 || transcript.length > 0;
const isLive = activeTurns.length > 0;
const latestActivityAtByChannel = collectLatestActivityAtByChannel({
activeTurns,
events,
transcript,
});
if (isLive) {
const channelIds = [...activeTurns]
.map((turn) => turn.channelId)
.sort((left, right) => left.localeCompare(right));
return {
channelIds,
hasFeedContent: true,
isLive: true,
latestActivityAtByChannel,
preferredChannelId: channelIds[0] ?? null,
};
}
const feedChannelIds = collectChannelIdsFromFeed(events, transcript);
const latestChannelId = deriveLatestChannelId(events, transcript);
return {
channelIds: feedChannelIds,
hasFeedContent,
isLive: false,
latestActivityAtByChannel,
preferredChannelId: latestChannelId,
};
}
export function useProfileActivityFeedScope(
activityAgent: ProfileActivityAgent | null,
activeTurns: readonly ActiveTurnSummary[],
): ProfileActivityFeedScope {
const agentCacheKey = activityAgent
? normalizePubkey(activityAgent.pubkey)
: "none";
const hasObserver =
activityAgent !== null && isManagedAgentActive(activityAgent);
const getSnapshot = React.useCallback(() => {
if (!activityAgent || !hasObserver) {
return stableFeedScope(
agentCacheKey,
deriveProfileActivityFeedScope({
activeTurns,
events: [],
transcript: [],
}),
);
}
const { events } = getAgentObserverSnapshot(activityAgent.pubkey, true);
const transcript = getAgentTranscript(activityAgent.pubkey, true);
return stableFeedScope(
agentCacheKey,
deriveProfileActivityFeedScope({ activeTurns, events, transcript }),
);
}, [activeTurns, activityAgent, agentCacheKey, hasObserver]);
const snapshot = React.useSyncExternalStore((onStoreChange) => {
const unsubscribeObserver = subscribeAgentObserverStore(onStoreChange);
const unsubscribeTurns = subscribeActiveAgentTurns(onStoreChange);
return () => {
unsubscribeObserver();
unsubscribeTurns();
};
}, getSnapshot);
return snapshot;
}

View File

@@ -53,6 +53,8 @@ import {
useUserProfileQuery,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import { resolveProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent";
import {
AgentInfoFocusedView,
AgentInstructionsFocusedView,
@@ -90,7 +92,6 @@ import type {
Channel,
CreateManagedAgentInput,
CreatePersonaInput,
ManagedAgent,
UpdatePersonaInput,
} from "@/shared/api/types";
import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame";
@@ -269,47 +270,32 @@ export function UserProfilePanel({
// the relay routes and the client decrypts those frames with the owner's OWN
// key, so the agent's seckey is never needed. Computed here (before the gates
// that consume it) so visibility keys off declared ownership, not key custody.
const isCurrentUserOwner =
currentPubkey !== undefined &&
ownerPubkey !== null &&
ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey);
// The viewer may see owner-scoped data if they declared-own the agent OR they
// manage it locally (older agents may not advertise an owner pubkey). Every
// real boundary is server-side, so this only controls what UI we paint.
const viewerIsOwner = isCurrentUserOwner || isOwner === true;
const activityAgent = React.useMemo(
() =>
resolveProfileActivityAgent({
effectivePubkey,
isBot,
managedAgent,
profile: profile ?? null,
relayAgent,
viewerIsOwner,
}),
[effectivePubkey, isBot, managedAgent, profile, relayAgent, viewerIsOwner],
);
const activityBridgeAgents = React.useMemo(
() => (activityAgent ? [activityAgent] : []),
[activityAgent],
);
// Populate the active-turns store for this agent so useActiveAgentTurns works
// even if the Agents page hasn't been visited yet.
const bridgeAgents = React.useMemo(
() =>
managedAgent
? [{ pubkey: managedAgent.pubkey, status: managedAgent.status }]
: [],
[managedAgent],
);
// The observer bridge subscribes on the OWNER's own pubkey and decrypts the
// agent's telemetry with the owner's key — no agent seckey needed. It only
// decrypts frames whose agent pubkey is "known", and only subscribes when an
// agent is running/deployed. For a remote agent we own but don't manage
// locally, `managedAgent` is undefined, so we seed the bridge from the relay
// agent (treated as "deployed") when the viewer is the declared owner. This
// mirrors what the composer-area ingress already does in ChannelScreen.
const observerBridgeAgents = React.useMemo(() => {
if (managedAgent) {
return [{ pubkey: managedAgent.pubkey, status: managedAgent.status }];
}
if (viewerIsOwner && relayAgent) {
return [
{
pubkey: relayAgent.pubkey,
status: "deployed" as ManagedAgent["status"],
},
];
}
return [];
}, [managedAgent, relayAgent, viewerIsOwner]);
useActiveAgentTurnsBridge(bridgeAgents);
useManagedAgentObserverBridge(observerBridgeAgents);
useActiveAgentTurnsBridge(activityBridgeAgents);
useManagedAgentObserverBridge(activityBridgeAgents);
const canEditAgent =
isOwner === true &&
(managedAgent !== undefined ||
@@ -680,10 +666,13 @@ export function UserProfilePanel({
],
);
const handleOpenActivity = React.useCallback(() => {
if (!effectivePubkey) return;
onOpenAgentSession?.(effectivePubkey);
}, [effectivePubkey, onOpenAgentSession]);
const handleOpenActivity = React.useCallback(
(channelId?: string | null) => {
if (!effectivePubkey) return;
onOpenAgentSession?.(effectivePubkey, channelId ?? null);
},
[effectivePubkey, onOpenAgentSession],
);
const handleOpenChannel = React.useCallback(
(channelId: string) => {
@@ -835,6 +824,7 @@ export function UserProfilePanel({
isFollowing={isFollowing}
isOwner={viewerIsOwner}
isSelf={isSelf}
activityAgent={activityAgent}
managedAgent={managedAgent}
memoriesLoading={memoryQuery.isLoading}
memoryCount={memoryCount}

View File

@@ -17,12 +17,11 @@ import { toast } from "sonner";
import { MemorySection } from "@/features/agent-memory/ui/MemorySection";
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel";
import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import type { ProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent";
import type {
useFollowMutation,
useUnfollowMutation,
@@ -54,7 +53,6 @@ import type {
} from "@/features/profile/ui/UserProfilePanelUtils";
import { useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import { useNow } from "@/shared/lib/useNow";
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
import { Badge } from "@/shared/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -64,6 +62,7 @@ export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfileP
// ── Summary view ─────────────────────────────────────────────────────────────
export type ProfileSummaryViewProps = {
activityAgent: ProfileActivityAgent | null;
canAddToChannel: boolean;
canEditAgent: boolean;
canOpenAgentLogs: boolean;
@@ -95,7 +94,7 @@ export type ProfileSummaryViewProps = {
agentSettingsFields: ProfileField[];
diagnosticsFields: ProfileField[];
onAddToChannel: () => void;
onOpenActivity: () => void;
onOpenActivity: (channelId?: string | null) => void;
onOpenChannel: (channelId: string) => void;
onOpenDiagnostics: () => void;
onOpenInstructions: () => void;
@@ -172,6 +171,7 @@ function RuntimeTabStatusDot({ status }: { status: RuntimeTabStatus }) {
}
export function ProfileSummaryView({
activityAgent,
canAddToChannel,
canEditAgent,
canOpenAgentLogs,
@@ -217,7 +217,6 @@ export function ProfileSummaryView({
unfollowMutation,
userStatus,
}: ProfileSummaryViewProps) {
const { goChannel } = useAppNavigation();
const activeTurns = useActiveAgentTurns(isBot ? pubkey : null);
const showMemoriesTab = isOwner === true && Boolean(pubkey);
@@ -371,20 +370,6 @@ export function ProfileSummaryView({
/>
) : null}
{activeTurns.length > 0 ? (
<div className="flex flex-wrap justify-center gap-1.5">
{activeTurns.map(({ channelId, anchorAt }) => (
<ProfileWorkingBadge
key={channelId}
channelId={channelId}
name={channelIdToName[channelId] ?? channelId}
anchorAt={anchorAt}
onNavigate={goChannel}
/>
))}
</div>
) : null}
{showTabSection ? (
<section className="space-y-3">
{showTabBar ? (
@@ -396,7 +381,10 @@ export function ProfileSummaryView({
) : null}
{activeTab === "info" ? (
<ProfileInfoTabContent
activeTurns={activeTurns}
activityAgent={activityAgent}
agentInfoFields={agentInfoFields}
channelIdToName={channelIdToName}
isArchived={isArchived}
onOpenActivity={onOpenActivity}
pubkey={pubkey}
@@ -450,30 +438,6 @@ export function ProfileSummaryView({
);
}
function ProfileWorkingBadge({
channelId,
name,
anchorAt,
onNavigate,
}: {
channelId: string;
name: string;
anchorAt: number;
onNavigate: (channelId: string) => void;
}) {
const now = useNow(1000);
return (
<Badge
className="cursor-pointer motion-safe:animate-pulse normal-case tracking-normal hover:opacity-80"
variant="default"
onClick={() => onNavigate(channelId)}
>
Working in #{name} · {formatElapsed(now - anchorAt)}
</Badge>
);
}
// ── Hero & metadata ──────────────────────────────────────────────────────────
function ProfileHero({

View File

@@ -2,10 +2,18 @@ import * as React from "react";
import type { LucideIcon } from "lucide-react";
import { Activity, Archive, ChevronRight, Info, Wrench } from "lucide-react";
import type { ActiveTurnSummary } from "@/features/agents/activeAgentTurnsStore";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
import {
AgentDetailsRows,
AgentInstructionRow,
} from "@/features/profile/ui/UserProfilePanelAgentDetails";
import type { ProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent";
import { resolveActivityChannelId } from "@/features/profile/lib/profileActivityCarousel";
import {
type ProfileActivityFeedScope,
useProfileActivityFeedScope,
} from "@/features/profile/lib/profileActivityFeedScope";
import {
type ProfileField,
ProfileFieldGroup,
@@ -13,7 +21,14 @@ import {
} from "@/features/profile/ui/UserProfilePanelFields";
import type { ProfilePanelTab } from "@/features/profile/ui/UserProfilePanelUtils";
import { cn } from "@/shared/lib/cn";
import { useNow } from "@/shared/lib/useNow";
import { Button } from "@/shared/ui/button";
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
} from "@/shared/ui/carousel";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
export function ProfileIngressRow({
@@ -255,15 +270,21 @@ export function ProfileTabBar({
}
export function ProfileInfoTabContent({
activeTurns,
activityAgent,
agentInfoFields,
channelIdToName,
isArchived,
onOpenActivity,
pubkey,
showActivityIngress,
}: {
activeTurns: ActiveTurnSummary[];
activityAgent: ProfileActivityAgent | null;
agentInfoFields: ProfileField[];
channelIdToName: Record<string, string>;
isArchived: boolean;
onOpenActivity: () => void;
onOpenActivity: (channelId?: string | null) => void;
pubkey: string | null;
showActivityIngress: boolean;
}) {
@@ -280,6 +301,9 @@ export function ProfileInfoTabContent({
]
: agentInfoFields;
const hasInfoFields = infoFields.length > 0;
const feedScope = useProfileActivityFeedScope(activityAgent, activeTurns);
const showLiveActivityEmbed =
showActivityIngress && (feedScope.isLive || feedScope.hasFeedContent);
if (!hasInfoFields && !showActivityIngress) {
return null;
@@ -288,19 +312,390 @@ export function ProfileInfoTabContent({
return (
<div className="space-y-2">
{showActivityIngress ? (
<ProfileIngressRow
icon={Wrench}
label="Activity log"
onClick={onOpenActivity}
testId={`user-profile-view-activity-${pubkey}`}
trailing="View"
/>
showLiveActivityEmbed && activityAgent ? (
<ProfileLiveActivityEmbed
activeTurns={activeTurns}
activityAgent={activityAgent}
channelIdToName={channelIdToName}
feedScope={feedScope}
onOpenActivity={onOpenActivity}
/>
) : (
<ProfileIngressRow
icon={Wrench}
label="Activity log"
onClick={() => onOpenActivity(null)}
testId={`user-profile-view-activity-${pubkey}`}
trailing="View"
/>
)
) : null}
{hasInfoFields ? <ProfileFieldGroup fields={infoFields} /> : null}
</div>
);
}
function ProfileLiveActivityEmbed({
activeTurns,
activityAgent,
channelIdToName,
feedScope,
onOpenActivity,
}: {
activeTurns: ActiveTurnSummary[];
activityAgent: ProfileActivityAgent;
channelIdToName: Record<string, string>;
feedScope: ProfileActivityFeedScope;
onOpenActivity: (channelId?: string | null) => void;
}) {
const [carouselApi, setCarouselApi] = React.useState<CarouselApi>();
const [selectedChannelId, setSelectedChannelId] = React.useState<
string | null
>(null);
const [mountedChannelIds, setMountedChannelIds] = React.useState<Set<string>>(
() => new Set(),
);
const slides = React.useMemo(() => {
const channelIds = feedScope.isLive
? activeTurns.map((turn) => turn.channelId)
: feedScope.channelIds;
return [...new Set(channelIds)];
}, [activeTurns, feedScope.channelIds, feedScope.isLive]);
const activeChannelId = resolveActivityChannelId(
slides,
selectedChannelId,
feedScope.preferredChannelId,
);
const selectedIndex = activeChannelId ? slides.indexOf(activeChannelId) : 0;
React.useEffect(() => {
if (!carouselApi || !activeChannelId) {
return;
}
const syncSelectedChannel = () => {
setSelectedChannelId(slides[carouselApi.selectedScrollSnap()] ?? null);
};
carouselApi.on("select", syncSelectedChannel);
carouselApi.on("reInit", syncSelectedChannel);
return () => {
carouselApi.off("select", syncSelectedChannel);
carouselApi.off("reInit", syncSelectedChannel);
};
}, [activeChannelId, carouselApi, slides]);
React.useEffect(() => {
if (!carouselApi || !activeChannelId) {
return;
}
const targetIndex = slides.indexOf(activeChannelId);
if (targetIndex >= 0 && carouselApi.selectedScrollSnap() !== targetIndex) {
carouselApi.scrollTo(targetIndex, true);
}
setSelectedChannelId(activeChannelId);
}, [activeChannelId, carouselApi, slides]);
React.useEffect(() => {
if (!activeChannelId) {
return;
}
setMountedChannelIds((current) => {
if (current.has(activeChannelId)) {
return current;
}
const next = new Set(current);
next.add(activeChannelId);
return next;
});
}, [activeChannelId]);
const selectedTurn = feedScope.isLive
? (activeTurns.find((turn) => turn.channelId === activeChannelId) ??
activeTurns[0] ??
null)
: null;
const activeChannelName = activeChannelId
? (channelIdToName[activeChannelId] ?? activeChannelId)
: null;
const lastLiveAt =
(activeChannelId
? feedScope.latestActivityAtByChannel[activeChannelId]
: undefined) ??
selectedTurn?.anchorAt ??
null;
const emptyState = feedScope.isLive ? "loading" : "idle";
const emptyDescription = "Live activity will appear here.";
const openSelectedActivity = React.useCallback(() => {
onOpenActivity(activeChannelId);
}, [activeChannelId, onOpenActivity]);
const handleDotSelect = React.useCallback(
(index: number) => {
const targetIndex =
slides.length === 2 && index === selectedIndex
? (selectedIndex + 1) % slides.length
: index;
carouselApi?.scrollTo(targetIndex);
},
[carouselApi, selectedIndex, slides.length],
);
if (slides.length === 0) {
return (
<section
aria-label={`Open activity feed. Last live ${formatLastLiveLabel(lastLiveAt, Date.now())}.`}
className="relative flex h-56 cursor-pointer flex-col overflow-hidden rounded-2xl border bg-background text-left shadow-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`user-profile-live-activity-${activityAgent.pubkey}`}
>
<button
aria-label={`Open activity feed. Last live ${formatLastLiveLabel(lastLiveAt, Date.now())}.`}
className="absolute inset-0 z-10 rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={openSelectedActivity}
type="button"
/>
<LiveActivityOpenButton
activeChannelId={activeChannelId}
lastLiveAt={lastLiveAt}
onOpenActivity={onOpenActivity}
/>
<ManagedAgentSessionPanel
agent={activityAgent}
autoTail={true}
channelId={null}
className="relative z-0 min-h-0 flex-1 border-0 bg-transparent px-4 text-xs shadow-none **:data-message-id:pointer-events-none"
emptyDescription={emptyDescription}
emptyState={emptyState}
panelPadding={false}
rawLayout="responsive"
showHeader={false}
showRaw={false}
transcriptContentClassName="py-4"
transcriptVariant="compactPreview"
/>
<div className="pointer-events-none absolute inset-0 z-20">
<div className="absolute inset-x-0 bottom-0 flex flex-col items-start bg-linear-to-t from-background via-background/90 to-transparent px-3 pb-3 pt-24">
<div className="min-w-0">
<span className="block text-sm font-semibold text-muted-foreground">
Latest Activity
</span>
</div>
</div>
</div>
</section>
);
}
return (
<div>
<section
aria-label={`Open activity feed. Last live ${formatLastLiveLabel(lastLiveAt, Date.now())}.`}
className="relative flex h-56 cursor-pointer flex-col overflow-hidden rounded-2xl border bg-background text-left shadow-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`user-profile-live-activity-${activityAgent.pubkey}`}
>
<button
aria-label={`Open activity feed. Last live ${formatLastLiveLabel(lastLiveAt, Date.now())}.`}
className="absolute inset-0 z-10 rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={openSelectedActivity}
type="button"
/>
<LiveActivityOpenButton
activeChannelId={activeChannelId}
lastLiveAt={lastLiveAt}
onOpenActivity={onOpenActivity}
/>
<Carousel
className="relative z-0 flex min-h-0 flex-1 flex-col"
opts={{
align: "start",
containScroll: "trimSnaps",
dragFree: false,
watchDrag: false,
}}
setApi={setCarouselApi}
>
<CarouselContent className="ml-0 h-full flex-1">
{slides.map((channelId) => {
const isMounted = mountedChannelIds.has(channelId);
return (
<CarouselItem
className="h-full basis-full pl-0"
data-mounted={isMounted ? "true" : "false"}
data-testid={`user-profile-activity-slide-${channelId}`}
key={channelId}
>
{isMounted ? (
<ManagedAgentSessionPanel
agent={activityAgent}
autoTail={true}
channelId={channelId}
className="h-full min-h-0 border-0 bg-transparent px-4 text-xs shadow-none **:data-message-id:pointer-events-none"
emptyDescription={emptyDescription}
emptyState={emptyState}
panelPadding={false}
rawLayout="responsive"
showHeader={false}
showRaw={false}
transcriptContentClassName="py-4"
transcriptVariant="compactPreview"
/>
) : (
<div aria-hidden="true" className="h-full" />
)}
</CarouselItem>
);
})}
</CarouselContent>
</Carousel>
<div className="pointer-events-none absolute inset-0 z-20">
<div className="absolute inset-x-0 bottom-0 flex flex-col items-start bg-linear-to-t from-background via-background/80 to-transparent px-3 pb-3 pt-16">
<div className="min-w-0">
<span className="block text-xs font-semibold text-muted-foreground">
Latest Activity
</span>
{activeChannelName ? (
<span
className="block truncate text-xs font-medium text-muted-foreground/75"
data-testid="user-profile-activity-channel-label"
title={`#${activeChannelName}`}
>
#{activeChannelName}
</span>
) : null}
</div>
</div>
</div>
</section>
<ActivityCarouselDots
channelIdToName={channelIdToName}
onSelect={handleDotSelect}
selectedIndex={selectedIndex}
slides={slides}
/>
</div>
);
}
function ActivityCarouselDots({
channelIdToName,
onSelect,
selectedIndex,
slides,
}: {
channelIdToName: Record<string, string>;
onSelect: (index: number) => void;
selectedIndex: number;
slides: string[];
}) {
if (slides.length <= 1) {
return null;
}
return (
<div
aria-label="Choose active channel feed"
className="mt-2 flex items-center justify-center gap-1.5"
role="tablist"
>
{slides.map((channelId, index) => {
const isSelected = index === selectedIndex;
const channelName = channelIdToName[channelId] ?? channelId;
return (
<button
aria-label={`Show #${channelName} activity`}
aria-selected={isSelected}
className="group relative flex items-center justify-center before:absolute before:-inset-2 before:content-['']"
data-testid={`user-profile-activity-dot-${channelId}`}
key={channelId}
onClick={(event) => {
event.stopPropagation();
onSelect(index);
}}
role="tab"
type="button"
>
<span
aria-hidden="true"
className={cn(
"relative z-10 block rounded-full bg-foreground transition-all",
isSelected
? "h-1 w-4"
: "h-1 w-1 opacity-30 group-hover:opacity-60",
)}
/>
</button>
);
})}
</div>
);
}
function LiveActivityOpenButton({
activeChannelId,
lastLiveAt,
onOpenActivity,
}: {
activeChannelId: string | null;
lastLiveAt: number | null;
onOpenActivity: (channelId?: string | null) => void;
}) {
const now = useNow(15_000);
const label = formatLastLiveLabel(lastLiveAt, now);
return (
<Button
aria-label={`Open full activity. Last live ${label}.`}
className="absolute right-3 top-3 z-40 rounded-full bg-primary px-2.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90"
onClick={(event) => {
event.stopPropagation();
onOpenActivity(activeChannelId);
}}
size="xs"
title={`Last live ${label}`}
type="button"
>
{label}
</Button>
);
}
function formatLastLiveLabel(timestamp: number | null, now: number): string {
if (timestamp === null) {
return "No activity yet";
}
const elapsedMs = Math.max(0, now - timestamp);
const totalSeconds = Math.floor(elapsedMs / 1000);
if (totalSeconds < 60) {
return "Just now";
}
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) {
return `${totalMinutes}m ago`;
}
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours < 24) {
return `${totalHours}h ago`;
}
const totalDays = Math.floor(totalHours / 24);
if (totalDays < 7) {
return `${totalDays}d ago`;
}
const totalWeeks = Math.floor(totalDays / 7);
return `${totalWeeks}w ago`;
}
function ArchiveStatusTooltip() {
return (
<Tooltip>

View File

@@ -23,7 +23,10 @@ import {
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import { truncatePubkey } from "@/features/profile/lib/identity";
import {
ownsAuthorAgent,
truncatePubkey,
} from "@/features/profile/lib/identity";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useUserStatusQuery } from "@/features/user-status/hooks";
@@ -230,7 +233,6 @@ export function UserProfilePopover({
// shape as the pane/sidebar/memory fixes. Every real boundary is server-side;
// this only decides whether to paint the "View activity log" button.
const isOwner = useIsManagedAgent(isBotProfile ? pubkey : null);
const ownerPubkey = profile?.ownerPubkey ?? null;
const identityQuery = useIdentityQuery();
const currentPubkey = identityQuery.data?.pubkey;
const isSelf =
@@ -240,10 +242,7 @@ export function UserProfilePopover({
const showHumanProfileActions =
showProfileActions && !isBotProfile && !isAgentClassificationPending;
const selfProfileQuery = useProfileQuery(open && showProfileActions);
const isCurrentUserOwner =
currentPubkey !== undefined &&
ownerPubkey !== null &&
ownerPubkey.toLowerCase() === currentPubkey.toLowerCase();
const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey);
const viewerIsOwner = isCurrentUserOwner || isOwner === true;
const canViewActivity =
isBotProfile && viewerIsOwner && Boolean(onOpenAgentSession);

View File

@@ -1,7 +1,9 @@
import * as React from "react";
type AgentSessionContextValue = {
onOpenAgentSession: ((pubkey: string) => void) | null;
onOpenAgentSession:
| ((pubkey: string, channelId?: string | null) => void)
| null;
};
const AgentSessionContext = React.createContext<AgentSessionContextValue>({
@@ -13,7 +15,7 @@ export function AgentSessionProvider({
onOpenAgentSession,
}: {
children: React.ReactNode;
onOpenAgentSession: (pubkey: string) => void;
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
}) {
const value = React.useMemo(
() => ({ onOpenAgentSession }),

View File

@@ -0,0 +1,74 @@
import type * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/shared/lib/cn";
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
export function SimpleImageLightbox({
alt,
children,
onOpenChange,
open,
src,
}: {
alt: string;
children?: React.ReactNode;
onOpenChange: (open: boolean) => void;
open: boolean;
src: string;
}) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
MODAL_BACKDROP_BLUR_CLASS,
)}
/>
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
onInteractOutside={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">
{alt}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size image preview. Press Escape or click outside the image to
close.
</DialogPrimitive.Description>
<DialogPrimitive.Close
aria-label="Close lightbox"
className="absolute inset-0 cursor-default"
/>
{children ?? (
<img
alt={alt}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={src}
/>
)}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6l12 12"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View File

@@ -0,0 +1,722 @@
import { useId, useLayoutEffect, useRef } from "react";
import type { CSSProperties } from "react";
import "./buzz-logo-animation.css";
const LOOP = "indefinite";
const EASE = ".16 1 .3 1";
type TextureKey = "soft" | "fuzzy";
type VariantKey = "v1" | "v2" | "v3" | "v4" | "v5" | "v6" | "v7" | "v8";
type Timing = [string, string];
type PartTimings = Record<string, Timing>;
type VariantConfig = {
duration: string;
texture?: TextureKey;
body: PartTimings;
leftEye: PartTimings;
rightEye: PartTimings;
topSlot: PartTimings;
bottomSlot: PartTimings;
leftSide: PartTimings;
rightSide: PartTimings;
};
type TextureConfig = {
blur: string;
displacement: string;
frequency: string;
frequencyValues: string;
grainAlpha: string;
seedValues: string;
};
export type BuzzLogoAnimationProps = {
ariaLabel?: string;
className?: string;
fullScreen?: boolean;
loop?: boolean;
/**
* When looping, hide the mark for this many seconds between plays. The morph
* runs at its native speed, then the mark disappears for the rest window
* before the cycle repeats. Only applies when `loop` is true.
*/
loopRestSeconds?: number;
reverse?: boolean;
showBackground?: boolean;
style?: CSSProperties;
/** When false, skips the looping feTurbulence texture filter (CPU-heavy). */
textured?: boolean;
variant?: VariantKey;
};
const TEXTURES: Record<TextureKey, TextureConfig> = {
soft: {
blur: "6.4",
displacement: "7",
frequency: "0.86",
frequencyValues: "0.82;0.98;0.78;1.04;0.82",
grainAlpha: ".34",
seedValues: "4;13;7;19;4",
},
fuzzy: {
blur: "9",
displacement: "10",
frequency: "1.06",
frequencyValues: "1;1.18;0.96;1.24;1",
grainAlpha: ".5",
seedValues: "6;21;11;27;6",
},
};
const VARIANTS: Record<string, VariantConfig> = {
v1: {
duration: "2s",
body: {
x: [
"0;0.1;0.22;0.29;0.39;0.45;0.82;1",
"186;186;124;128;128;128;128;128",
],
y: ["0;0.1;0.22;0.29;0.39;0.45;0.82;1", "108;108;45;49;-5;0;0;0"],
width: [
"0;0.1;0.22;0.29;0.39;0.45;0.82;1",
"93;93;218;210;210;210;210;210",
],
height: [
"0;0.1;0.22;0.29;0.39;0.45;0.82;1",
"93;93;218;210;318;309;309;309",
],
rx: ["0;0.1;0.22;0.29;0.39;0.45;0.82;1", "14;14;38;34;36;34;34;34"],
},
leftEye: {
cx: ["0;0.42;0.5;0.55;0.82;1", "233.4;233.4;188.5;193.3;193.3;193.3"],
cy: ["0;0.42;0.5;0.55;0.82;1", "154.5;154.5;80.5;84.4;84.4;84.4"],
},
rightEye: {
cx: ["0;0.42;0.5;0.55;0.82;1", "233.4;233.4;280;276;276;276"],
cy: ["0;0.42;0.5;0.55;0.82;1", "154.5;154.5;80.5;84.4;84.4;84.4"],
},
topSlot: {
opacity: ["0;0.57;0.58;0.69;1", "0;0;1;1;1"],
x: ["0;0.57;0.64;0.69;1", "234.8;234.8;162;166.3;166.3"],
width: ["0;0.57;0.64;0.69;1", "0;0;146;136.9;136.9"],
},
bottomSlot: {
opacity: ["0;0.72;0.73;0.86;1", "0;0;1;1;1"],
x: ["0;0.72;0.8;0.86;1", "234.8;234.8;162;166.9;166.9"],
width: ["0;0.72;0.8;0.86;1", "0;0;146;136.2;136.2"],
},
leftSide: {
opacity: ["0;0.84;0.85;1", "0;0;1;1"],
cx: ["0;0.78;0.88;0.94;1", "233;233;233;86;91.7"],
},
rightSide: {
opacity: ["0;0.84;0.85;1", "0;0;1;1"],
cx: ["0;0.78;0.88;0.94;1", "233;233;233;380;374.3"],
},
},
v2: {
duration: "1.9s",
body: {
x: ["0;0.16;0.33;0.4;0.52;0.6;1", "186;186;124;124;128;128;128"],
y: ["0;0.16;0.33;0.4;0.52;0.6;1", "108;108;45;45;-6;0;0"],
width: ["0;0.16;0.33;0.4;0.52;0.6;1", "93;93;218;218;210;210;210"],
height: ["0;0.16;0.33;0.4;0.52;0.6;1", "93;93;218;218;318;309;309"],
rx: ["0;0.16;0.33;0.4;0.52;0.6;1", "14;14;38;38;36;34;34"],
},
leftEye: {
cx: ["0;0.47;0.64;0.7;1", "233.4;233.4;188.5;193.3;193.3"],
cy: ["0;0.47;0.64;0.7;1", "154.5;154.5;80.5;84.4;84.4"],
},
rightEye: {
cx: ["0;0.47;0.64;0.7;1", "233.4;233.4;280;276;276"],
cy: ["0;0.47;0.64;0.7;1", "154.5;154.5;80.5;84.4;84.4"],
},
topSlot: {
opacity: ["0;0.62;0.63;1", "0;0;1;1"],
x: ["0;0.62;0.76;0.82;1", "234.8;234.8;162;166.3;166.3"],
width: ["0;0.62;0.76;0.82;1", "0;0;146;136.9;136.9"],
},
bottomSlot: {
opacity: ["0;0.72;0.73;1", "0;0;1;1"],
x: ["0;0.72;0.86;0.92;1", "234.8;234.8;162;166.9;166.9"],
width: ["0;0.72;0.86;0.92;1", "0;0;146;136.2;136.2"],
},
leftSide: {
opacity: ["0;0.81;0.82;1", "0;0;1;1"],
cx: ["0;0.82;0.94;1", "233;233;82;91.7"],
},
rightSide: {
opacity: ["0;0.81;0.82;1", "0;0;1;1"],
cx: ["0;0.82;0.94;1", "233;233;384;374.3"],
},
},
};
VARIANTS.v3 = { ...VARIANTS.v1, duration: "1.45s" };
VARIANTS.v4 = {
duration: "0.95s",
body: {
x: ["0;0.18;0.31;0.42;1", "186;124;128;128;128"],
y: ["0;0.18;0.31;0.42;1", "108;45;-6;0;0"],
width: ["0;0.18;0.31;1", "93;218;210;210"],
height: ["0;0.18;0.31;0.42;1", "93;218;318;309;309"],
rx: ["0;0.18;0.31;0.42;1", "14;38;36;34;34"],
},
leftEye: {
cx: ["0;0.3;0.52;0.62;1", "233.4;233.4;188.5;193.3;193.3"],
cy: ["0;0.3;0.52;0.62;1", "154.5;154.5;80.5;84.4;84.4"],
},
rightEye: {
cx: ["0;0.3;0.52;0.62;1", "233.4;233.4;280;276;276"],
cy: ["0;0.3;0.52;0.62;1", "154.5;154.5;80.5;84.4;84.4"],
},
topSlot: {
opacity: ["0;0.46;0.47;1", "0;0;1;1"],
x: ["0;0.46;0.66;0.76;1", "234.8;234.8;162;166.3;166.3"],
width: ["0;0.46;0.66;0.76;1", "0;0;146;136.9;136.9"],
},
bottomSlot: {
opacity: ["0;0.58;0.59;1", "0;0;1;1"],
x: ["0;0.58;0.76;0.86;1", "234.8;234.8;162;166.9;166.9"],
width: ["0;0.58;0.76;0.86;1", "0;0;146;136.2;136.2"],
},
leftSide: {
opacity: ["0;0.7;0.71;1", "0;0;1;1"],
cx: ["0;0.7;0.9;1", "233;233;82;91.7"],
},
rightSide: {
opacity: ["0;0.7;0.71;1", "0;0;1;1"],
cx: ["0;0.7;0.9;1", "233;233;384;374.3"],
},
};
VARIANTS.v5 = {
duration: "0.88s",
body: {
opacity: ["0;0.03;0.08;1", "0;0;1;1"],
x: [
"0;0.08;0.16;0.23;0.35;0.47;0.6;1",
"233.4;200;186;186;120;132;127;128",
],
y: ["0;0.08;0.16;0.23;0.35;0.47;0.6;1", "154.5;121;108;108;42;-12;3;0"],
width: ["0;0.08;0.16;0.23;0.35;0.47;0.6;1", "0;66;93;93;226;204;212;210"],
height: ["0;0.08;0.16;0.23;0.35;0.47;0.6;1", "0;66;93;93;226;326;304;309"],
rx: ["0;0.08;0.16;0.23;0.35;0.47;0.6;1", "0;33;46.5;14;40;37;33;34"],
},
leftEye: {
opacity: ["0;0.18;0.2;1", "0;0;1;1"],
cx: ["0;0.23;0.45;0.58;0.68;1", "233.4;233.4;185;196;193.3;193.3"],
cy: ["0;0.23;0.45;0.58;0.68;1", "154.5;154.5;76;87;84.4;84.4"],
},
rightEye: {
opacity: ["0;0.18;0.2;1", "0;0;1;1"],
cx: ["0;0.23;0.45;0.58;0.68;1", "233.4;233.4;283;273;276;276"],
cy: ["0;0.23;0.45;0.58;0.68;1", "154.5;154.5;76;87;84.4;84.4"],
},
topSlot: {
opacity: ["0;0.42;0.43;1", "0;0;1;1"],
x: ["0;0.42;0.6;0.72;0.82;1", "234.8;234.8;158;169;166.3;166.3"],
width: ["0;0.42;0.6;0.72;0.82;1", "0;0;153;132;136.9;136.9"],
},
bottomSlot: {
opacity: ["0;0.54;0.55;1", "0;0;1;1"],
x: ["0;0.54;0.72;0.84;0.94;1", "234.8;234.8;158;169;166.9;166.9"],
width: ["0;0.54;0.72;0.84;0.94;1", "0;0;153;132;136.2;136.2"],
},
leftSide: {
opacity: ["0;0.66;0.67;1", "0;0;1;1"],
cx: ["0;0.66;0.84;0.96;1", "233;233;76;95;91.7"],
},
rightSide: {
opacity: ["0;0.66;0.67;1", "0;0;1;1"],
cx: ["0;0.66;0.84;0.96;1", "233;233;390;371;374.3"],
},
};
function scaleTiming([keyTimes, values]: Timing, scale: number): Timing {
const times = keyTimes.split(";").map((time) => Number(time) * scale);
const splitValues = values.split(";");
const lastTime = times[times.length - 1];
if (lastTime !== undefined && lastTime < 1) {
times.push(1);
const lastValue = splitValues[splitValues.length - 1];
if (lastValue !== undefined) {
splitValues.push(lastValue);
}
}
return [
times.map((time) => String(Number(time.toFixed(4)))).join(";"),
splitValues.join(";"),
];
}
function scaleVariant(
variant: VariantConfig,
duration: string,
scale: number,
): VariantConfig {
return Object.fromEntries(
Object.entries(variant).map(([part, timings]) => {
if (part === "duration") {
return [part, duration];
}
if (part === "texture") {
return [part, timings];
}
return [
part,
Object.fromEntries(
Object.entries(timings as PartTimings).map(([attribute, timing]) => [
attribute,
scaleTiming(timing, scale),
]),
),
];
}),
) as VariantConfig;
}
function reverseTiming([keyTimes, values]: Timing): Timing {
const times = keyTimes.split(";").map((time) => 1 - Number(time));
const splitValues = values.split(";");
const pairs = times
.map((time, index) => ({ time, value: splitValues[index] }))
.reverse();
return [
pairs.map(({ time }) => String(Number(time.toFixed(4)))).join(";"),
pairs.map(({ value }) => value).join(";"),
];
}
function reverseVariant(variant: VariantConfig): VariantConfig {
return Object.fromEntries(
Object.entries(variant).map(([part, timings]) => {
if (part === "duration" || part === "texture") {
return [part, timings];
}
return [
part,
Object.fromEntries(
Object.entries(timings as PartTimings).map(([attribute, timing]) => [
attribute,
reverseTiming(timing),
]),
),
];
}),
) as VariantConfig;
}
VARIANTS.v6 = scaleVariant(VARIANTS.v5, "1.38s", 0.64);
VARIANTS.v6.leftEye.ry = [
"0;0.64;0.72;0.78;0.84;0.9;0.96;1",
"27;27;2;27;27;2;27;27",
];
VARIANTS.v6.rightEye.ry = VARIANTS.v6.leftEye.ry;
VARIANTS.v7 = {
...VARIANTS.v5,
texture: "fuzzy",
leftSide: {
...VARIANTS.v5.leftSide,
opacity: ["0;0.54;0.55;1", "0;0;1;1"],
cx: ["0;0.54;0.74;0.9;1", "233;233;76;95;91.7"],
},
rightSide: {
...VARIANTS.v5.rightSide,
opacity: ["0;0.54;0.55;1", "0;0;1;1"],
cx: ["0;0.54;0.74;0.9;1", "233;233;390;371;374.3"],
},
};
VARIANTS.v8 = {
...VARIANTS.v7,
leftEye: {
...VARIANTS.v7.leftEye,
ry: ["0;0.76;0.84;0.92;1", "27;27;2;27;27"],
},
rightEye: {
...VARIANTS.v7.rightEye,
ry: ["0;0.76;0.84;0.92;1", "27;27;2;27;27"],
},
};
function splines(keyTimes: string) {
return Array.from(
{ length: keyTimes.split(";").length - 1 },
() => EASE,
).join(";");
}
function SvgAnimate({
attributeName,
duration,
keyTimes,
repeatCount,
values,
}: {
attributeName: string;
duration: string;
keyTimes: string;
repeatCount: string;
values: string;
}) {
return (
<animate
attributeName={attributeName}
begin="indefinite"
dur={duration}
calcMode="spline"
fill={repeatCount === LOOP ? "remove" : "freeze"}
keySplines={splines(keyTimes)}
keyTimes={keyTimes}
repeatCount={repeatCount}
values={values}
/>
);
}
function animationsFor(
config: PartTimings,
duration: string,
repeatCount: string,
) {
return Object.entries(config).map(([attributeName, [keyTimes, values]]) => (
<SvgAnimate
key={attributeName}
attributeName={attributeName}
duration={duration}
keyTimes={keyTimes}
repeatCount={repeatCount}
values={values}
/>
));
}
function idPart(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "");
}
function TextureFilter({
id,
texture,
}: {
id: string;
texture: TextureConfig;
}) {
return (
<filter
id={id}
x="-80"
y="-80"
width="626"
height="469"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur
in="SourceGraphic"
stdDeviation={texture.blur}
result="softLogo"
/>
<feTurbulence
type="fractalNoise"
baseFrequency={texture.frequency}
numOctaves="5"
seed="7"
result="textureNoise"
>
<animate
attributeName="baseFrequency"
begin="indefinite"
dur="0.34s"
repeatCount={LOOP}
values={texture.frequencyValues}
/>
<animate
attributeName="seed"
begin="indefinite"
dur="0.34s"
repeatCount={LOOP}
values={texture.seedValues}
/>
</feTurbulence>
<feDisplacementMap
in="softLogo"
in2="textureNoise"
scale={texture.displacement}
xChannelSelector="R"
yChannelSelector="G"
result="buzzedLogo"
/>
<feColorMatrix
in="textureNoise"
type="matrix"
values={`0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
${texture.grainAlpha} ${texture.grainAlpha} ${texture.grainAlpha} 0 0`}
result="grainAlpha"
/>
<feComposite
in="buzzedLogo"
in2="grainAlpha"
operator="in"
result="grainLogo"
/>
<feMerge>
<feMergeNode in="buzzedLogo" />
<feMergeNode in="grainLogo" />
</feMerge>
</filter>
);
}
function CutoutMask({
config,
duration,
id,
repeatCount,
}: {
config: VariantConfig;
duration: string;
id: string;
repeatCount: string;
}) {
return (
<mask
id={id}
x="-80"
y="-80"
width="626"
height="469"
maskUnits="userSpaceOnUse"
maskContentUnits="userSpaceOnUse"
>
<rect x="-80" y="-80" width="626" height="469" fill="#fff" />
<ellipse
cx="233.4"
cy="154.5"
rx="27"
ry="27"
fill="#000"
opacity={"opacity" in config.leftEye ? "0" : undefined}
>
{animationsFor(config.leftEye, duration, repeatCount)}
</ellipse>
<ellipse
cx="233.4"
cy="154.5"
rx="27"
ry="27"
fill="#000"
opacity={"opacity" in config.rightEye ? "0" : undefined}
>
{animationsFor(config.rightEye, duration, repeatCount)}
</ellipse>
<rect
x="234.8"
y="157.2"
width="0"
height="38.3"
rx="5"
fill="#000"
opacity="0"
>
{animationsFor(config.topSlot, duration, repeatCount)}
</rect>
<rect
x="234.8"
y="235.1"
width="0"
height="37.6"
rx="5"
fill="#000"
opacity="0"
>
{animationsFor(config.bottomSlot, duration, repeatCount)}
</rect>
</mask>
);
}
function InkShapes({
config,
duration,
repeatCount,
}: {
config: VariantConfig;
duration: string;
repeatCount: string;
}) {
return (
<>
<circle
className="buzz-logo__ink"
cx="233"
cy="154.5"
r="91.7"
opacity="0"
>
{animationsFor(config.leftSide, duration, repeatCount)}
</circle>
<circle
className="buzz-logo__ink"
cx="233"
cy="154.5"
r="91.7"
opacity="0"
>
{animationsFor(config.rightSide, duration, repeatCount)}
</circle>
<rect
className="buzz-logo__ink"
x="186"
y="108"
width="93"
height="93"
rx="14"
opacity={"opacity" in config.body ? "0" : undefined}
>
{animationsFor(config.body, duration, repeatCount)}
</rect>
</>
);
}
/**
* Hides the parent group during the rest window of a stretched loop cycle:
* fully visible while the morph plays, then a quick fade to invisible for the
* remainder of the cycle. SMIL `<animate>` targets its parent element.
*/
function RestWindowFade({
cycleSeconds,
morphSeconds,
repeatCount,
}: {
cycleSeconds: number;
morphSeconds: number;
repeatCount: string;
}) {
const fadeSeconds = 0.15;
const visibleEnd = morphSeconds / cycleSeconds;
const fadeEnd = Math.min((morphSeconds + fadeSeconds) / cycleSeconds, 1);
const keyTimes = ["0", visibleEnd.toFixed(4), fadeEnd.toFixed(4), "1"].join(
";",
);
return (
<animate
attributeName="opacity"
begin="indefinite"
calcMode="linear"
dur={`${cycleSeconds}s`}
fill={repeatCount === LOOP ? "remove" : "freeze"}
keyTimes={keyTimes}
repeatCount={repeatCount}
values="1;1;0;0"
/>
);
}
export default function BuzzLogoAnimation({
ariaLabel = "Buzz logo animation",
className = "",
fullScreen = true,
loop = false,
loopRestSeconds = 0,
reverse = false,
showBackground = true,
style,
textured = true,
variant = "v8",
}: BuzzLogoAnimationProps) {
const markRef = useRef<SVGSVGElement>(null);
const idSuffix = idPart(useId());
const baseConfig = VARIANTS[variant] ?? VARIANTS.v8;
const restSeconds = loop ? Math.max(loopRestSeconds, 0) : 0;
const morphSeconds = Number.parseFloat(baseConfig.duration);
const cycleSeconds = morphSeconds + restSeconds;
// Stretch the loop period to morph + rest, packing the morph keyframes into
// the start of the cycle at native speed. scaleTiming holds the final values
// for the remainder; the rest-window opacity animation below hides them.
const config =
restSeconds > 0
? scaleVariant(
baseConfig,
`${cycleSeconds}s`,
morphSeconds / cycleSeconds,
)
: baseConfig;
const animatedConfig = reverse ? reverseVariant(config) : config;
const repeatCount = loop ? LOOP : "1";
const maskId = `buzz-logo-cutouts-${idSuffix}`;
const textureId = `buzz-logo-texture-${idSuffix}`;
const texture = TEXTURES[config.texture ?? "soft"] ?? TEXTURES.soft;
const classes = [
"buzz-logo",
fullScreen && "buzz-logo--screen",
!fullScreen && "buzz-logo--compact",
showBackground && "buzz-logo--background",
className,
]
.filter(Boolean)
.join(" ");
// biome-ignore lint/correctness/useExhaustiveDependencies: restart SMIL when visual props change
useLayoutEffect(() => {
const svg = markRef.current;
if (!svg || typeof svg.setCurrentTime !== "function") {
return;
}
svg.pauseAnimations?.();
svg.setCurrentTime?.(0);
svg.querySelectorAll("animate").forEach((animation) => {
animation.beginElement?.();
});
svg.unpauseAnimations?.();
}, [loop, reverse, restSeconds, textured, variant]);
return (
<div className={classes} style={style} role="img" aria-label={ariaLabel}>
<svg
ref={markRef}
className="buzz-logo__mark"
viewBox="0 0 466 309"
width="466"
height="309"
aria-hidden="true"
>
<defs>
<CutoutMask
id={maskId}
config={animatedConfig}
duration={animatedConfig.duration}
repeatCount={repeatCount}
/>
{textured && <TextureFilter id={textureId} texture={texture} />}
</defs>
<g filter={textured ? `url(#${textureId})` : undefined}>
{restSeconds > 0 ? (
<RestWindowFade
cycleSeconds={cycleSeconds}
morphSeconds={morphSeconds}
repeatCount={repeatCount}
/>
) : null}
<g mask={`url(#${maskId})`}>
<InkShapes
config={animatedConfig}
duration={animatedConfig.duration}
repeatCount={repeatCount}
/>
</g>
</g>
</svg>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { cn } from "@/shared/lib/cn";
import BuzzLogoAnimation, {
type BuzzLogoAnimationProps,
} from "./BuzzLogoAnimation";
export type FuzzyLogoProps = {
/** When false, skips the looping feTurbulence texture filter and uses a CSS pulse instead. */
fuzz?: boolean;
className?: string;
ariaLabel?: string;
loop?: boolean;
/** When looping, hide the mark for this many seconds between plays. */
loopRestSeconds?: number;
/** Set false when a parent drives its own opacity animation over the mark. */
pulse?: boolean;
reverse?: boolean;
variant?: BuzzLogoAnimationProps["variant"];
};
/**
* The fuzzy Buzz mark. v8 ships a built-in animated texture (looping fractal-noise
* turbulence + grain) applied via an SVG filter. Set `fuzz={false}` to render the
* crisp geometry with a lightweight CSS pulse — recommended for long-lived mounts.
*/
export function FuzzyLogo({
fuzz = true,
className,
ariaLabel = "Buzz logo",
loop = false,
loopRestSeconds = 0,
pulse = true,
reverse = false,
variant = "v8",
}: FuzzyLogoProps) {
// The rest-window loop already reads as "alive"; skip the pulse so the two
// opacity animations don't fight.
const hasRestWindow = loop && loopRestSeconds > 0;
return (
<BuzzLogoAnimation
ariaLabel={ariaLabel}
className={cn(
pulse && !fuzz && !hasRestWindow && "buzz-logo--pulse",
className,
)}
fullScreen={false}
loop={loop}
loopRestSeconds={loopRestSeconds}
reverse={reverse}
showBackground={false}
textured={fuzz}
variant={variant}
/>
);
}

View File

@@ -0,0 +1,68 @@
.buzz-logo {
display: grid;
place-items: center;
inline-size: fit-content;
}
.buzz-logo--screen {
min-block-size: 100vh;
inline-size: 100%;
overflow: hidden;
}
.buzz-logo--background {
background-color: #d7d72e;
background-image: radial-gradient(
circle,
rgba(35, 30, 30, 0.16) 1.2px,
transparent 1.3px
);
background-position: 0 0;
background-size: 37px 37px;
}
.buzz-logo__mark {
display: block;
inline-size: min(466px, calc(100vw - 32px));
block-size: auto;
overflow: visible;
}
.buzz-logo--compact {
inline-size: 1.5rem;
}
.buzz-logo--compact .buzz-logo__mark {
inline-size: 100%;
block-size: auto;
max-inline-size: 100%;
}
.buzz-logo__ink {
fill: currentColor;
}
@keyframes buzz-logo-pulse {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 1;
}
}
.buzz-logo--pulse .buzz-logo__mark {
animation: buzz-logo-pulse 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.buzz-logo animate {
display: none;
}
.buzz-logo--pulse .buzz-logo__mark {
animation: none;
opacity: 0.8;
}
}

View File

@@ -0,0 +1,265 @@
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref,
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((carouselApi: CarouselApi) => {
if (!carouselApi) {
return;
}
setCanScrollPrev(carouselApi.canScrollPrev());
setCanScrollNext(carouselApi.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
api,
canScrollNext,
canScrollPrev,
carouselRef,
opts,
orientation,
plugins,
scrollNext,
scrollPrev,
setApi,
}}
>
{/* biome-ignore lint/a11y/useSemanticElements: shadcn carousel pattern */}
<div
aria-roledescription="carousel"
className={cn("relative", className)}
onKeyDownCapture={handleKeyDown}
ref={ref}
role="region"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div className="h-full overflow-hidden" ref={carouselRef}>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
ref={ref}
{...props}
/>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { orientation } = useCarousel();
return (
// biome-ignore lint/a11y/useSemanticElements: shadcn carousel pattern
<div
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
ref={ref}
role="group"
{...props}
>
{children}
</div>
);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { canScrollPrev, orientation, scrollPrev } = useCarousel();
return (
<Button
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
ref={ref}
size={size}
variant={variant}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { canScrollNext, orientation, scrollNext } = useCarousel();
return (
<Button
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
ref={ref}
size={size}
variant={variant}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
};

View File

@@ -74,7 +74,6 @@ import type {
ImetaEntry,
MarkdownProps,
MarkdownRuntime,
MarkdownVariant,
} from "./markdown/types";
import { SpoilerInline } from "./markdown/SpoilerInline";
import {
@@ -1527,17 +1526,10 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
}
function createMarkdownComponents(
variant: MarkdownVariant,
runtimeRef: React.RefObject<MarkdownRuntime>,
interactive = true,
mediaInset = false,
): Components {
const paragraphClassName =
variant === "tight"
? "leading-5"
: variant === "compact"
? "leading-6"
: "leading-[inherit]";
const listItemClassName = "[&_p]:inline";
const listClassName = "space-y-1 pl-6 marker:text-muted-foreground/80";
@@ -1782,10 +1774,10 @@ function createMarkdownComponents(
}
if (hasBlockMedia(childArray)) {
return <div className={paragraphClassName}>{children}</div>;
return <div>{children}</div>;
}
return <p className={paragraphClassName}>{children}</p>;
return <p>{children}</p>;
},
pre: ({ children }) => {
if (!interactive) return <span>{children}</span>;
@@ -1940,7 +1932,6 @@ function createMarkdownComponents(
function MarkdownInner({
channelNames,
className,
compact = false,
content,
customEmoji,
imetaByUrl,
@@ -1950,7 +1941,6 @@ function MarkdownInner({
mentionNames,
mentionPubkeysByName,
searchQuery,
tight = false,
videoReviewContext,
}: MarkdownProps) {
const { channels: rawChannels } = useChannelNavigation();
@@ -1991,16 +1981,9 @@ function MarkdownInner({
onOpenMessageLink,
});
const variant: MarkdownVariant = tight
? "tight"
: compact
? "compact"
: "default";
const components = React.useMemo(
() =>
createMarkdownComponents(variant, runtimeRef, interactive, mediaInset),
[variant, runtimeRef, interactive, mediaInset],
() => createMarkdownComponents(runtimeRef, interactive, mediaInset),
[runtimeRef, interactive, mediaInset],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
@@ -2055,7 +2038,7 @@ function MarkdownInner({
className={cn(
MESSAGE_MARKDOWN_CLASS,
[
"max-w-none [overflow-wrap:anywhere] text-sm leading-5 text-foreground",
"max-w-none wrap-anywhere text-sm leading-5 text-foreground",
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"[&>*+*]:mt-3",
"[&>p+p]:mt-1.5",
@@ -2068,13 +2051,6 @@ function MarkdownInner({
"[&>*+hr]:mt-4 [&>hr+*]:mt-4",
"[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5",
].join(" "),
// Variant overrides: density tweaks for agent-session transcript surfaces.
// Layered after the base owl-spacing set so tailwind-merge lets the
// narrower leading + tighter inter-block gaps win for compact/tight.
variant === "compact" &&
"leading-6 [&>*+*]:mt-2 [&>*+h1]:mt-3 [&>*+h2]:mt-3 [&>*+h3]:mt-3 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1",
variant === "tight" &&
"leading-5 [&>*+*]:mt-2 [&>*+h1]:mt-2.5 [&>*+h2]:mt-2.5 [&>*+h3]:mt-2.5 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1",
className,
)}
>
@@ -2100,11 +2076,9 @@ export const Markdown = React.memo(
(prev, next) =>
prev.content === next.content &&
prev.className === next.className &&
prev.compact === next.compact &&
prev.customEmoji === next.customEmoji &&
prev.interactive === next.interactive &&
prev.mediaInset === next.mediaInset &&
prev.tight === next.tight &&
prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName &&
prev.mentionPubkeysByName === next.mentionPubkeysByName &&
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&

View File

@@ -35,7 +35,6 @@ export type MarkdownRuntime = {
export type MarkdownProps = {
channelNames?: string[];
className?: string;
compact?: boolean;
content: string;
customEmoji?: CustomEmoji[];
imetaByUrl?: ImetaLookup;
@@ -45,8 +44,5 @@ export type MarkdownProps = {
mentionPubkeysByName?: Record<string, string>;
mediaInset?: boolean;
searchQuery?: string;
tight?: boolean;
videoReviewContext?: VideoReviewContext;
};
export type MarkdownVariant = "default" | "compact" | "tight";

View File

@@ -8,7 +8,10 @@ import { relayClient } from "@/shared/api/relayClient";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import type { RelayEvent } from "@/shared/api/types";
import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore";
import { injectObserverEventsForE2E } from "@/features/agents/observerRelayStore";
import {
injectObserverEventsForE2E,
syncAgentObserverEvents,
} from "@/features/agents/observerRelayStore";
import {
CUSTOM_EMOJI_SET_D_TAG,
KIND_EMOJI_SET,
@@ -699,6 +702,7 @@ declare global {
agentPubkey: string;
channelId: string;
turnId: string;
kind?: "turn_started" | "turn_completed";
}) => void;
__BUZZ_E2E_SEED_OBSERVER_EVENTS__?: (input: {
agentPubkey: string;
@@ -7570,20 +7574,21 @@ export function maybeInstallE2eTauriMocks() {
agentPubkey,
channelId,
turnId,
kind = "turn_started",
}) => {
seedTurnSeq += 1;
syncAgentTurnsFromEvents(agentPubkey, [
{
seq: seedTurnSeq,
timestamp: new Date().toISOString(),
kind: "turn_started",
agentIndex: 0,
channelId,
sessionId: null,
turnId,
payload: null,
},
]);
const event = {
seq: seedTurnSeq,
timestamp: new Date().toISOString(),
kind,
agentIndex: 0,
channelId,
sessionId: null,
turnId,
payload: null,
};
syncAgentTurnsFromEvents(agentPubkey, [event]);
syncAgentObserverEvents(agentPubkey, [event]);
};
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ = ({ agentPubkey, events }) => {
injectObserverEventsForE2E(agentPubkey, events);

View File

@@ -35,7 +35,29 @@ function resolveSourcePath(basePath) {
return `${basePath}.ts`;
}
// emoji-mart ships a bundled CJS main that node's cjs-module-lexer cannot
// extract named exports from (`import { init } from "emoji-mart"` throws
// under node ESM even though the bundler handles it). Tests never exercise
// the picker, so serve inert stubs for the emoji-mart entrypoints.
const stubModules = new Map([
[
"emoji-mart",
"export const init = () => {};\n" +
"export const SearchIndex = { search: async () => [] };\n" +
"export default {};\n",
],
["@emoji-mart/react", "export default function Picker() { return null; }\n"],
]);
const STUB_URL_PREFIX = "buzz-test-stub:";
export function resolve(specifier, context, nextResolve) {
if (stubModules.has(specifier)) {
return {
shortCircuit: true,
url: `${STUB_URL_PREFIX}${specifier}`,
};
}
if (specifier === "@features-manifest") {
const resolved = path.join(repoRoot, "preview-features.json");
return nextResolve(resolved, context);
@@ -69,6 +91,26 @@ export function resolve(specifier, context, nextResolve) {
}
export async function load(url, context, nextLoad) {
if (url.startsWith(STUB_URL_PREFIX)) {
return {
format: "module",
shortCircuit: true,
source: stubModules.get(url.slice(STUB_URL_PREFIX.length)) ?? "",
};
}
// The app bundler loads .json imports without attributes (e.g. the bare
// `@emoji-mart/data` entrypoint); node's ESM resolver requires
// `with { type: "json" }` on every hop. Serve json here so transitive
// imports from source under test don't need bundler-only semantics.
if (url.endsWith(".json")) {
return {
format: "json",
shortCircuit: true,
source: fs.readFileSync(fileURLToPath(url), "utf8"),
};
}
if (url.endsWith(".tsx")) {
const source = fs.readFileSync(fileURLToPath(url), "utf8");
const transpiled = ts.transpileModule(source, {

View File

@@ -118,10 +118,16 @@ test.describe("active turn badge resilience", () => {
]);
const paulPanel = await openAgentProfile(page, AGENT_PAUL);
await expect(paulPanel).toContainText("Working in #general", {
timeout: 5_000,
});
await expect(paulPanel).toContainText("Working in #engineering");
await expect(paulPanel).toBeVisible();
// The profile panel surfaces active turns via the live-activity embed only
// where an agent session can open (channel surfaces). In the Agents view
// the store-driven working state shows as sidebar channel badges — the
// same activeAgentTurnsStore this test exercises.
const generalBadge = page.getByTestId("channel-working-general");
const engineeringBadge = page.getByTestId("channel-working-engineering");
await expect(generalBadge).toBeVisible({ timeout: 5_000 });
await expect(engineeringBadge).toBeVisible();
// Simulate the all-at-once relay drop: no further frames, advance the clock
// past both thresholds. This fires several real prune ticks; shouldPausePrune
@@ -130,7 +136,7 @@ test.describe("active turn badge resilience", () => {
// pre-fix code every badge would be gone after the first tick past 25s.
await page.clock.fastForward(FRAME_GAP_MS);
await expect(paulPanel).toContainText("Working in #general");
await expect(paulPanel).toContainText("Working in #engineering");
await expect(generalBadge).toBeVisible();
await expect(engineeringBadge).toBeVisible();
});
});

View File

@@ -8,6 +8,7 @@ import {
} from "../helpers/bridge";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
// Relay-only agent owned by the mock viewer (see e2eBridge.ts
// OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and
@@ -17,6 +18,12 @@ const OWNED_RELAY_AGENT_PUBKEY =
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
type MockFeedWindow = Window & {
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
kind?: "turn_started" | "turn_completed";
}) => void;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention" | "needs_action" | "activity" | "agent_activity";
channel_id: string | null;
@@ -1142,6 +1149,156 @@ test("members sidebar exposes view-activity for a viewer-owned relay agent", asy
).toBeVisible();
});
test("profile renders live activity for a viewer-owned relay agent", async ({
page,
}) => {
await page.goto("/");
await openMembersSidebar(page, "agents");
await page
.getByTestId(`sidebar-member-open-profile-${OWNED_RELAY_AGENT_PUBKEY}`)
.click();
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
await expect(
page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`),
).toBeVisible();
await page.waitForFunction(
() =>
typeof (window as MockFeedWindow).__BUZZ_E2E_SEED_ACTIVE_TURNS__ ===
"function",
);
await page.evaluate(
({ agentPubkey, channelId }) => {
const seedActiveTurns = (window as MockFeedWindow)
.__BUZZ_E2E_SEED_ACTIVE_TURNS__;
if (!seedActiveTurns) {
throw new Error("Mock active-turn helper is not installed.");
}
seedActiveTurns({
agentPubkey,
channelId,
turnId: "owned-relay-profile-turn",
});
},
{
agentPubkey: OWNED_RELAY_AGENT_PUBKEY,
channelId: AGENTS_CHANNEL_ID,
},
);
const liveActivity = page.getByTestId(
`user-profile-live-activity-${OWNED_RELAY_AGENT_PUBKEY}`,
);
await expect(liveActivity).toBeVisible();
await expect(liveActivity).toContainText("Latest Activity");
await expect(liveActivity).toContainText("#agents");
await expect(
page.getByTestId(`user-profile-activity-dot-${AGENTS_CHANNEL_ID}`),
).toHaveCount(0);
await page.evaluate(
({ agentPubkey, channelId, turnId }) => {
const seedActiveTurns = (window as MockFeedWindow)
.__BUZZ_E2E_SEED_ACTIVE_TURNS__;
if (!seedActiveTurns) {
throw new Error("Mock active-turn helper is not installed.");
}
seedActiveTurns({
agentPubkey,
channelId,
turnId,
kind: "turn_completed",
});
},
{
agentPubkey: OWNED_RELAY_AGENT_PUBKEY,
channelId: AGENTS_CHANNEL_ID,
turnId: "owned-relay-profile-turn",
},
);
await expect(liveActivity).toBeVisible();
await expect(liveActivity).toContainText("Latest Activity");
await expect(
page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`),
).not.toBeVisible();
});
test("profile activity carousel switches channels via progress dots", async ({
page,
}) => {
await page.goto("/");
await openMembersSidebar(page, "agents");
await page
.getByTestId(`sidebar-member-open-profile-${OWNED_RELAY_AGENT_PUBKEY}`)
.click();
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
await expect(
page.getByTestId(`user-profile-view-activity-${OWNED_RELAY_AGENT_PUBKEY}`),
).toBeVisible();
await page.waitForFunction(
() =>
typeof (window as MockFeedWindow).__BUZZ_E2E_SEED_ACTIVE_TURNS__ ===
"function",
);
await page.evaluate(
({ agentPubkey, channels }) => {
const seedActiveTurns = (window as MockFeedWindow)
.__BUZZ_E2E_SEED_ACTIVE_TURNS__;
if (!seedActiveTurns) {
throw new Error("Mock active-turn helper is not installed.");
}
for (const [index, channelId] of channels.entries()) {
seedActiveTurns({
agentPubkey,
channelId,
turnId: `owned-relay-profile-turn-${index}`,
});
}
},
{
agentPubkey: OWNED_RELAY_AGENT_PUBKEY,
channels: [AGENTS_CHANNEL_ID, GENERAL_CHANNEL_ID],
},
);
const liveActivity = page.getByTestId(
`user-profile-live-activity-${OWNED_RELAY_AGENT_PUBKEY}`,
);
await expect(liveActivity).toBeVisible();
await expect(liveActivity).toContainText("#agents");
await expect(
page.getByTestId(`user-profile-activity-dot-${AGENTS_CHANNEL_ID}`),
).toBeVisible();
await expect(
page.getByTestId(`user-profile-activity-dot-${GENERAL_CHANNEL_ID}`),
).toBeVisible();
await expect(
page.getByTestId(`user-profile-activity-slide-${GENERAL_CHANNEL_ID}`),
).toHaveAttribute("data-mounted", "false");
await page
.getByTestId(`user-profile-activity-dot-${GENERAL_CHANNEL_ID}`)
.click();
await expect(
page.getByTestId("user-profile-activity-channel-label"),
).toContainText("#general");
await expect(
page.getByTestId(`user-profile-activity-slide-${GENERAL_CHANNEL_ID}`),
).toHaveAttribute("data-mounted", "true");
await expect(
page.getByTestId(`user-profile-activity-slide-${AGENTS_CHANNEL_ID}`),
).toHaveAttribute("data-mounted", "true");
});
test("typing indicator shows avatars and maintains stable name order", async ({
page,
}) => {

View File

@@ -91,8 +91,18 @@ async function seedObserverEvents(
}
async function settleAnimations(panel: import("@playwright/test").Locator) {
// Only await finite animations — live surfaces (e.g. the turn liveness
// indicator) run infinite loops whose `finished` promise never resolves.
await panel.evaluate((el) =>
Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)),
Promise.all(
el
.getAnimations({ subtree: true })
.filter((a) => {
const timing = a.effect?.getTiming();
return timing?.iterations !== Number.POSITIVE_INFINITY;
})
.map((a) => a.finished),
),
);
}
@@ -114,15 +124,14 @@ test.describe("observer feed screenshots", () => {
});
});
test("01 — prompt context inline (collapsed-but-labeled)", async ({
page,
}) => {
test("01 — prompt context dialog (via Checks ingress)", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
// session/prompt event: the per-turn prompt context that #1381 stopped
// rendering inline. The payload contains sections that parsePromptText
// extracts and the transcript renders as a collapsed PromptContextInline.
// session/prompt event: per-turn prompt context. The payload contains
// sections that parsePromptText extracts; the transcript keeps them out
// of the feed until the CheckCheck footer toggle opens the
// PromptContextDialog modal.
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
@@ -156,13 +165,22 @@ test.describe("observer feed screenshots", () => {
},
]);
// The inline context element should be visible with collapsed sections.
// The context stays out of the feed until the CheckCheck ingress opens
// the dialog.
await expect(feedPanel.getByText("Prompt context")).toHaveCount(0);
const contextToggle = feedPanel.getByTestId(
"transcript-prompt-context-toggle",
);
await expect(contextToggle).toBeVisible({ timeout: 5_000 });
await contextToggle.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(
feedPanel.getByTestId("transcript-prompt-context-inline"),
dialog.getByTestId("transcript-prompt-context-sections"),
).toBeVisible({ timeout: 5_000 });
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/01-prompt-context-inline.png`,
await settleAnimations(dialog);
await dialog.screenshot({
path: `${SHOTS}/01-prompt-context-dialog.png`,
});
});
@@ -323,7 +341,7 @@ test.describe("observer feed screenshots", () => {
});
});
test("05 — prompt context inline (sections expanded)", async ({ page }) => {
test("05 — prompt context dialog (sections expanded)", async ({ page }) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
@@ -360,19 +378,23 @@ test.describe("observer feed screenshots", () => {
},
]);
await expect(
feedPanel.getByTestId("transcript-prompt-context-inline"),
).toBeVisible({ timeout: 5_000 });
// Open the dialog via the CheckCheck ingress, then expand every section.
const contextToggle = feedPanel.getByTestId(
"transcript-prompt-context-toggle",
);
await expect(contextToggle).toBeVisible({ timeout: 5_000 });
await contextToggle.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Click each section accordion button to expand it.
const sectionButtons = feedPanel
const sectionButtons = dialog
.getByTestId("transcript-prompt-context-sections")
.getByRole("button");
for (const btn of await sectionButtons.all()) {
await btn.click();
}
await settleAnimations(feedPanel);
await feedPanel.screenshot({
await settleAnimations(dialog);
await dialog.screenshot({
path: `${SHOTS}/05-prompt-context-expanded.png`,
});
});
@@ -618,7 +640,7 @@ test.describe("observer feed screenshots", () => {
});
});
test("11 — first-turn ordering: user bubble → System prompt → Prompt context", async ({
test("11 — first-turn bundle: user bubble + Checks ingress dialog", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
@@ -626,8 +648,9 @@ test.describe("observer feed screenshots", () => {
// Full realistic pool.rs first-turn wire sequence:
// turn_started → session/new → session_resolved → session/prompt
// Verifies the ordering fix: System prompt renders between the user message
// bubble and the Prompt context inline block (not after it).
// Verifies the prompt bundle keeps context out of the feed: the system
// prompt and prompt context both live in the dialog opened via the
// CheckCheck footer ingress, with system-prompt sections listed first.
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
{
seq: 1,
@@ -699,15 +722,24 @@ test.describe("observer feed screenshots", () => {
await expect(feedPanel.getByTestId("transcript-prompt-bundle")).toBeVisible(
{ timeout: 5_000 },
);
// System prompt should appear inside the bundle, above prompt context.
await expect(feedPanel.getByText("System prompt")).toBeVisible({
timeout: 5_000,
});
await expect(feedPanel.getByText("Prompt context")).toBeVisible({
timeout: 5_000,
});
await settleAnimations(feedPanel);
await feedPanel.screenshot({
// Neither the system prompt nor the prompt context renders in the feed.
await expect(feedPanel.getByText("System prompt")).toHaveCount(0);
await expect(feedPanel.getByText("Prompt context")).toHaveCount(0);
// Open the dialog: system-prompt sections (Base/System) come before the
// prompt-context sections (Buzz event/Thread context).
await feedPanel.getByTestId("transcript-prompt-context-toggle").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
const sectionTitles = await dialog
.getByTestId("transcript-prompt-context-sections")
.locator("article")
.allInnerTexts();
expect(sectionTitles.length).toBe(4);
expect(sectionTitles[0]).toContain("Base");
expect(sectionTitles[1]).toContain("System");
await settleAnimations(dialog);
await dialog.screenshot({
path: `${SHOTS}/11-first-turn-ordering.png`,
});
});

View File

@@ -94,9 +94,14 @@ test.describe("profile active turn indicator", () => {
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText("Working in #general", {
timeout: 5_000,
});
const liveActivity = panel.getByTestId(
`user-profile-live-activity-${AGENT_PUBKEY}`,
);
await expect(liveActivity).toBeVisible({ timeout: 5_000 });
await expect(liveActivity).toContainText("Latest Activity");
await expect(
liveActivity.getByTestId("user-profile-activity-channel-label"),
).toContainText("#general");
});
test("02 — profile panel: agent working in two channels", async ({
@@ -113,10 +118,18 @@ test.describe("profile active turn indicator", () => {
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText("Working in #general", {
timeout: 5_000,
});
await expect(panel).toContainText("Working in #engineering");
const liveActivity = panel.getByTestId(
`user-profile-live-activity-${AGENT_PUBKEY}`,
);
await expect(liveActivity).toBeVisible({ timeout: 5_000 });
await expect(liveActivity).toContainText("Latest Activity");
// One carousel dot per working channel.
await expect(
panel.getByTestId(`user-profile-activity-dot-${CHANNEL_GENERAL}`),
).toBeVisible();
await expect(
panel.getByTestId(`user-profile-activity-dot-${CHANNEL_ENGINEERING}`),
).toBeVisible();
});
test("03 — hover popover: agent working", async ({ page }) => {

View File

@@ -2,7 +2,12 @@ import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const CHANNEL_HISTORY_PREPEND_SETTLE_PX = 28;
// First-pass settle budget for a full channel-history prepend. CI Linux font
// rasterization can leave the restored anchor a subpixel off the local value
// (observed 28.5 vs 28), so keep headroom above the exact settle while staying
// well below a row-sized jump (~46px), which is what the real anchor-shove bug
// produces.
const CHANNEL_HISTORY_PREPEND_SETTLE_PX = 32;
async function getTimelineMetrics(page: import("@playwright/test").Page) {
return page.getByTestId("message-timeline").evaluate((element) => {

31
pnpm-lock.yaml generated
View File

@@ -132,6 +132,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.7)
emoji-mart:
specifier: ^5.6.0
version: 5.6.0
@@ -1641,7 +1644,6 @@ packages:
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
@@ -2089,6 +2091,19 @@ packages:
electron-to-chromium@1.5.361:
resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==}
embla-carousel-react@8.6.0:
resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==}
peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
embla-carousel-reactive-utils@8.6.0:
resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==}
peerDependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
emoji-mart@5.6.0:
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
@@ -3063,7 +3078,7 @@ packages:
engines: {node: '>= 0.4'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4739,6 +4754,18 @@ snapshots:
electron-to-chromium@1.5.361: {}
embla-carousel-react@8.6.0(react@19.2.7):
dependencies:
embla-carousel: 8.6.0
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
react: 19.2.7
embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0):
dependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0: {}
emoji-mart@5.6.0: {}
enhanced-resolve@5.21.5: