diff --git a/desktop/package.json b/desktop/package.json index cd33d1926..6f3f7855c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 9dafffdd5..e8474ed5e 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -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; diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx index 9b786d8ab..89aeba920 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -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) => { + 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) => { + 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 ( <>
- -
+ {openProfilePanel && !isCompactPreview ? ( + + ) : ( + + )} +
-

- {preview || "Message content unavailable."} -

+ + {hasBubbleOverflow ? ( + + ) : null} + {canOpenMessage ? : null}
); } + +function isNestedInteractiveTarget( + event: React.MouseEvent | React.KeyboardEvent, +) { + 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; +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx index 825adff68..2feeb8f97 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx @@ -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} /> ) : ( - + {label} )} @@ -69,7 +82,11 @@ export function CompactToolSummaryRow({ /> ) : !fileEditSummary && !actionLabel && 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); -} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx index 198762262..02a82534a 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx @@ -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 (
-

- - {command} -

+ +

+ + {command} +

+
{stdout ? ( -
-          {stdout}
-        
+ +
+            {stdout}
+          
+
) : null}
); diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx index 736a24622..0ac9a9195 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx @@ -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; }) { 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({ ))}
) : ( -

No todos.

+

+ No todos. +

)} @@ -72,8 +83,16 @@ export function isTodoSummary(summary: CompactToolSummary) { } function TodoCheckboxRow({ todo }: { todo: TodoDisplayItem }) { + const variant = useAgentSessionTranscriptVariant(); + const isCompactPreview = variant === "compactPreview"; + return ( -
+
; 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 (
@@ -56,6 +62,13 @@ export function ToolDetailBlocks({ {!showShellCommand && hasResult ? ( showFileEditDiff ? ( + ) : showFileReadContent ? ( + ) : ( @@ -113,9 +113,7 @@ export function ToolItem({ > @@ -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} />
diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx index 78d37dcb7..741730d51 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx @@ -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} /> - ); } - -function ImageLightbox({ - alt, - onOpenChange, - open, - src, -}: { - alt: string; - onOpenChange: (open: boolean) => void; - open: boolean; - src: string; -}) { - return ( - - - - event.preventDefault()} - onPointerDownOutside={(event) => event.preventDefault()} - > - - {alt} - - - Full-size image preview. Press Escape or click outside the image to - close. - - - {alt} - - - - - - - ); -} - -function resolveImageSrc(source: string): string { - return isInlineImageData(source) ? source : rewriteRelayUrl(source); -} diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 709d1a110..39c1900bb 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -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(null); + const contentRef = React.useRef(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 ( -
- -

No ACP activity yet

-

{emptyDescription}

+
+
+ {isLoading ? ( + + ) : ( + <> + +

No ACP activity yet

+

+ {emptyDescription} +

+ + )} +
); } return ( -
+
- {displayBlocks.map((block) => ( -
- -
- ))} + + {displayBlocks.map((block) => { + const blockKey = getDisplayBlockKey(block); + return ( + + {/* 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. */} +
+ +
+
+ ); + })} + {isTurnLive && !isCompactPreview ? : null} +
-
+ ); } +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 ( `), 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 ( {block.segments.map((segment) => ( - + transition={ROW_ENTER_SPRING} + > + + ))}
); @@ -398,89 +600,71 @@ function PromptUserMessage({ setup?: Extract[]; systemPrompt?: Extract | null; }) { + const [contextOpen, setContextOpen] = React.useState(false); + const contextSections = React.useMemo( + () => [...(systemPrompt?.sections ?? []), ...(context?.sections ?? [])], + [context, systemPrompt], + ); + return ( <> 0} items={setup} messageLink={getTranscriptMessageLink(item)} + onContextOpenChange={setContextOpen} timestamp={item.timestamp} /> } item={item} profiles={profiles} /> - {systemPrompt && systemPrompt.sections.length > 0 ? ( - - ) : null} - {context && context.sections.length > 0 ? ( - - ) : null} - - ); -} - -function PromptContextInline({ - context, -}: { - context: Extract; -}) { - const [dialogOpen, setDialogOpen] = React.useState(false); - - return ( - <> -
-
-

- {context.title} -

- -
- -
); } function PromptContextDialog({ - context, onOpenChange, open, + sections, + setup, }: { - context: Extract; onOpenChange: (open: boolean) => void; open: boolean; + sections: PromptSection[]; + setup: Extract[]; }) { - if (!open || context.sections.length === 0) { + if (!open || sections.length === 0) { return null; } + const setupText = formatPromptSetupSummary(setup); + return (
- {context.title} + Prompt context + {setupText ? ( +
+ + {setupText} +
+ ) : null}
- +
@@ -488,14 +672,28 @@ function PromptContextDialog({ ); } +function formatPromptSetupSummary( + items: Extract[], +) { + 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[]; 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 ? ( ) : null; @@ -515,10 +714,25 @@ function TurnSetupFooter({ className="flex items-center gap-1.5 text-muted-foreground/80" data-testid="transcript-turn-setup" > - - - {tooltipText} - + {showContext ? ( + + + ) : ( + + + {tooltipText} + + )} {showTimestamp ? ( ) : null} diff --git a/desktop/src/features/agents/ui/FileContentBlock.tsx b/desktop/src/features/agents/ui/FileContentBlock.tsx new file mode 100644 index 000000000..3e9766300 --- /dev/null +++ b/desktop/src/features/agents/ui/FileContentBlock.tsx @@ -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 ( +
+
+
{children}
+
+
+
+
+ ); +} + +export function FileContentBlock({ + footerText, + footerTitle, + lines, + path, +}: { + footerText?: string; + footerTitle?: string; + lines: FileContentLine[]; + path: string; +}) { + const resolvedFooterText = footerText ?? path; + + return ( +
+
+
+          
+        
+
+
+ {resolvedFooterText} +
+
+ ); +} + +function FileContentLines({ lines }: { lines: FileContentLine[] }) { + return lines.map((line, index) => ( + + )); +} + +function FileContentLineView({ line }: { line: FileContentLine }) { + return ( + + {line.text || " "} + + ); +} diff --git a/desktop/src/features/agents/ui/FileEditDiffView.tsx b/desktop/src/features/agents/ui/FileEditDiffView.tsx index 6a250a5f3..1a121c080 100644 --- a/desktop/src/features/agents/ui/FileEditDiffView.tsx +++ b/desktop/src/features/agents/ui/FileEditDiffView.tsx @@ -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 ( -
-
-        
-      
-
- {diff.path} -
-
+ line.kind !== "meta") + .map(toFileContentLine)} + path={diff.path} + /> ); } -function FileEditDiffLines({ diff }: { diff: FileEditDiff }) { - return diff.lines - .filter((line) => line.kind !== "meta") - .map((line, index) => ( - - )); -} - -function FileEditDiffLineView({ line }: { line: FileEditDiffLine }) { - return ( - - {line.text || " "} - - ); +function toFileContentLine(line: FileEditDiffLine): FileContentLine { + return { + kind: line.kind, + text: line.text, + }; } diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index ae4909cb4..eebedfdcb 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -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 & { 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 (
@@ -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} />
); @@ -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", )} > {rawRail.mode === "side" ? : null}
diff --git a/desktop/src/features/agents/ui/PromptSectionAccordion.tsx b/desktop/src/features/agents/ui/PromptSectionAccordion.tsx index 4fb25f820..0b5e58cc6 100644 --- a/desktop/src/features/agents/ui/PromptSectionAccordion.tsx +++ b/desktop/src/features/agents/ui/PromptSectionAccordion.tsx @@ -47,7 +47,7 @@ export function PromptSectionAccordion({
{section.title} @@ -55,7 +55,9 @@ export function PromptSectionAccordion({
{body.length > 0 ? ( diff --git a/desktop/src/features/agents/ui/TurnLivenessIndicator.tsx b/desktop/src/features/agents/ui/TurnLivenessIndicator.tsx new file mode 100644 index 000000000..611d79d9b --- /dev/null +++ b/desktop/src/features/agents/ui/TurnLivenessIndicator.tsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+ {MARKS.map((mark, index) => ( + + + + ))} +
+ ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx index 11fbe7ae9..54e97a59d 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx @@ -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({ > {verb} @@ -133,12 +138,13 @@ export function ActivityRowLabel({ {object ? ( {object} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx index 8428d73b7..00651b352 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -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 ( - - ); + return ; } function MessageItem({ - agentAvatarUrl, - agentName, - agentPubkey, item, profiles, -}: AgentTranscriptIdentityProps & { +}: { item: Extract; 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" >
-
- + - - {assistantLabel} - - -
-
-
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageLinkHoverCue.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageLinkHoverCue.tsx new file mode 100644 index 000000000..7b4e38d18 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageLinkHoverCue.tsx @@ -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 ( + + ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx index 3f7283312..334a1b080 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx @@ -39,8 +39,8 @@ export function PlanActivity(props: ActivityRenderClassItemProps) { diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx index fab78de10..8dfaea86e 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx @@ -22,8 +22,11 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) { title={formatTranscriptTimestampTitle(props.item.timestamp)} > - - + + ); diff --git a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx index 0fe9f98bf..09759129c 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx @@ -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, diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index a49cec218..181e4febf 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -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; 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) => { + 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) => { + 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 (
- + {isCompactPreview ? null : item.authorPubkey && openProfilePanel ? ( + + ) : ( + + )}
- + {children} + {hasBubbleOverflow ? ( + + ) : null} + {messageLink ? : null}
{footer}
); } + +function isNestedInteractiveTarget( + event: React.MouseEvent | React.KeyboardEvent, +) { + 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; +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/useTranscriptBubbleOverflow.ts b/desktop/src/features/agents/ui/activityRenderClasses/useTranscriptBubbleOverflow.ts new file mode 100644 index 000000000..92d228827 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/useTranscriptBubbleOverflow.ts @@ -0,0 +1,34 @@ +import * as React from "react"; + +export function useTranscriptBubbleOverflow(enabled: boolean) { + const ref = React.useRef(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; +} diff --git a/desktop/src/features/agents/ui/agentSessionFileRead.test.mjs b/desktop/src/features/agents/ui/agentSessionFileRead.test.mjs new file mode 100644 index 000000000..c19488a02 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionFileRead.test.mjs @@ -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); +}); diff --git a/desktop/src/features/agents/ui/agentSessionFileRead.ts b/desktop/src/features/agents/ui/agentSessionFileRead.ts new file mode 100644 index 000000000..d5fb7c9c3 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionFileRead.ts @@ -0,0 +1,156 @@ +import type { + AgentActivityDescriptor, + TranscriptItem, +} from "./agentSessionTypes"; +import { + asRecord, + getToolString, + parseToolResultValue, +} from "./agentSessionUtils"; + +type ToolItem = Extract; + +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, + 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); +} diff --git a/desktop/src/features/agents/ui/agentSessionImageContent.test.mjs b/desktop/src/features/agents/ui/agentSessionImageContent.test.mjs new file mode 100644 index 000000000..a5e033c0c --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionImageContent.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionImageContent.ts b/desktop/src/features/agents/ui/agentSessionImageContent.ts new file mode 100644 index 000000000..7eaa46147 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionImageContent.ts @@ -0,0 +1,40 @@ +import type { + AgentActivityDescriptor, + TranscriptItem, +} from "./agentSessionTypes"; +import { getToolString } from "./agentSessionUtils"; + +type ToolItem = Extract; + +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, + }; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs index a32c14a76..10efb79cb 100644 --- a/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs @@ -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", diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts index d5acfec2c..67f627134 100644 --- a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts @@ -87,6 +87,9 @@ const TOOL_CLASS_LABELS: Record = { 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 = { }; 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: { diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index bdfec16a0..fab1f539a 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -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: 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: "desktop/src/app/App.tsx", + 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", () => { diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index ddcd734dd..ed7edda93 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -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; } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts new file mode 100644 index 000000000..fd5554390 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts @@ -0,0 +1,13 @@ +import * as React from "react"; + +export type AgentSessionTranscriptVariant = "default" | "compactPreview"; + +const AgentSessionTranscriptVariantContext = + React.createContext("default"); + +export const AgentSessionTranscriptVariantProvider = + AgentSessionTranscriptVariantContext.Provider; + +export function useAgentSessionTranscriptVariant() { + return React.useContext(AgentSessionTranscriptVariantContext); +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index e16f1cd77..9c0bd30fc 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -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"); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 46e99eb09..e9d0fb0ce 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -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}`; } diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index c64702a98..4736358ef 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -24,6 +24,9 @@ export type AgentActivityRenderClass = | "message" | "relay-op" | "file-edit" + | "file-read" + | "skill-read" + | "image" | "shell" | "status" | "thought" diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts index 76404eae8..346454dee 100644 --- a/desktop/src/features/agents/ui/agentSessionUtils.ts +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -1,3 +1,5 @@ +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; + export function getToolString( record: Record, 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, keys: string[], diff --git a/desktop/src/features/agents/ui/transcriptAnimationPreference.ts b/desktop/src/features/agents/ui/transcriptAnimationPreference.ts new file mode 100644 index 000000000..45ec37e40 --- /dev/null +++ b/desktop/src/features/agents/ui/transcriptAnimationPreference.ts @@ -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); +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 572c7a780..4a2067143 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -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(); - 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()} > - { + event.preventDefault(); + handleRawFeedChange(!showRawFeed); + }} title={ showRawFeed ? "Hide raw JSON-RPC payloads." @@ -145,7 +189,45 @@ export function AgentSessionThreadPanel({ Show raw JSON-RPC activity. - +