mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
107
desktop/src/features/agents/ui/FileContentBlock.tsx
Normal file
107
desktop/src/features/agents/ui/FileContentBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
74
desktop/src/features/agents/ui/TurnLivenessIndicator.tsx
Normal file
74
desktop/src/features/agents/ui/TurnLivenessIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
141
desktop/src/features/agents/ui/agentSessionFileRead.test.mjs
Normal file
141
desktop/src/features/agents/ui/agentSessionFileRead.test.mjs
Normal 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);
|
||||
});
|
||||
156
desktop/src/features/agents/ui/agentSessionFileRead.ts
Normal file
156
desktop/src/features/agents/ui/agentSessionFileRead.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
40
desktop/src/features/agents/ui/agentSessionImageContent.ts
Normal file
40
desktop/src/features/agents/ui/agentSessionImageContent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ export type AgentActivityRenderClass =
|
||||
| "message"
|
||||
| "relay-op"
|
||||
| "file-edit"
|
||||
| "file-read"
|
||||
| "skill-read"
|
||||
| "image"
|
||||
| "shell"
|
||||
| "status"
|
||||
| "thought"
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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() ===
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
44
desktop/src/features/profile/lib/profileActivityAgent.ts
Normal file
44
desktop/src/features/profile/lib/profileActivityAgent.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
13
desktop/src/features/profile/lib/profileActivityCarousel.ts
Normal file
13
desktop/src/features/profile/lib/profileActivityCarousel.ts
Normal 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;
|
||||
}
|
||||
267
desktop/src/features/profile/lib/profileActivityFeedScope.ts
Normal file
267
desktop/src/features/profile/lib/profileActivityFeedScope.ts
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
74
desktop/src/shared/ui/SimpleImageLightbox.tsx
Normal file
74
desktop/src/shared/ui/SimpleImageLightbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
722
desktop/src/shared/ui/buzz-logo/BuzzLogoAnimation.tsx
Normal file
722
desktop/src/shared/ui/buzz-logo/BuzzLogoAnimation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
desktop/src/shared/ui/buzz-logo/FuzzyLogo.tsx
Normal file
55
desktop/src/shared/ui/buzz-logo/FuzzyLogo.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
desktop/src/shared/ui/buzz-logo/buzz-logo-animation.css
Normal file
68
desktop/src/shared/ui/buzz-logo/buzz-logo-animation.css
Normal 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;
|
||||
}
|
||||
}
|
||||
265
desktop/src/shared/ui/carousel.tsx
Normal file
265
desktop/src/shared/ui/carousel.tsx
Normal 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,
|
||||
};
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
31
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user