mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@sprout-oss.stage.blox.sqprod.co>
689 lines
18 KiB
TypeScript
689 lines
18 KiB
TypeScript
import * as React from "react";
|
|
import { CheckCheck, ChevronDown, Radio } from "lucide-react";
|
|
|
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/shared/ui/dialog";
|
|
import { Toggle } from "@/shared/ui/toggle";
|
|
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
|
|
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
|
|
import {
|
|
ActivityRow,
|
|
ActivityRowContent,
|
|
ActivityRowLabel,
|
|
type ActivityRowStats,
|
|
splitActivityRowLabel,
|
|
} from "./activityRenderClasses/ActivityRow";
|
|
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
|
|
import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types";
|
|
import type { FileEditDiff } from "./agentSessionFileEditDiff";
|
|
import {
|
|
buildTranscriptDisplayBlocks,
|
|
formatTurnSetupLabel,
|
|
turnSetupDetail,
|
|
turnSetupTimestamp,
|
|
type TranscriptDisplayBlock,
|
|
type TranscriptTurnSegment,
|
|
} from "./agentSessionTranscriptGrouping";
|
|
import { buildCompactToolSummary } from "./agentSessionToolSummary";
|
|
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
|
|
import { hasFileEditLineDiff } from "./FileEditDiffView";
|
|
import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";
|
|
|
|
const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";
|
|
|
|
/**
|
|
* 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();
|
|
|
|
function shouldShowTranscriptAcpSource() {
|
|
const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE;
|
|
if (envValue === "1" || envValue === "true") {
|
|
return true;
|
|
}
|
|
|
|
if (typeof window === "undefined") {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return (
|
|
window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1"
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function AgentSessionTranscriptList({
|
|
agentAvatarUrl,
|
|
agentName,
|
|
agentPubkey,
|
|
emptyDescription,
|
|
items,
|
|
profiles,
|
|
}: AgentTranscriptIdentityProps & {
|
|
emptyDescription: string;
|
|
items: TranscriptItem[];
|
|
profiles?: UserProfileLookup;
|
|
}) {
|
|
const displayBlocks = React.useMemo(
|
|
() => buildTranscriptDisplayBlocks(items),
|
|
[items],
|
|
);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div
|
|
aria-label="Live ACP transcript"
|
|
aria-live="polite"
|
|
className="flex w-full flex-col gap-2.5"
|
|
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>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TranscriptAcpSourceBadge({ source }: { source: string }) {
|
|
return (
|
|
<span
|
|
className="mb-1 inline-flex max-w-full rounded border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 font-mono text-xs leading-none text-amber-800 dark:text-amber-200"
|
|
data-testid="transcript-acp-source"
|
|
title={`ACP wire source: ${source}`}
|
|
>
|
|
{source}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function getDisplayBlockKey(block: TranscriptDisplayBlock) {
|
|
if (block.kind === "single") {
|
|
return block.item.id;
|
|
}
|
|
return `turn:${block.turnId}`;
|
|
}
|
|
|
|
function TranscriptDisplayBlockView({
|
|
agentAvatarUrl,
|
|
agentName,
|
|
agentPubkey,
|
|
block,
|
|
profiles,
|
|
}: AgentTranscriptIdentityProps & {
|
|
block: TranscriptDisplayBlock;
|
|
profiles?: UserProfileLookup;
|
|
}) {
|
|
if (block.kind === "single") {
|
|
return (
|
|
<TranscriptItemRow
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
item={block.item}
|
|
profiles={profiles}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col gap-2.5"
|
|
data-testid="transcript-turn-group"
|
|
data-turn-id={block.turnId}
|
|
>
|
|
{block.segments.map((segment) => (
|
|
<TranscriptTurnSegmentView
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
key={getTurnSegmentKey(block.turnId, segment)}
|
|
profiles={profiles}
|
|
segment={segment}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
|
|
if (segment.kind === "setup") {
|
|
return `turn:${turnId}:setup`;
|
|
}
|
|
if (segment.kind === "prompt") {
|
|
return `turn:${turnId}:prompt`;
|
|
}
|
|
if (segment.kind === "summary") {
|
|
return segment.summary.id;
|
|
}
|
|
return segment.item.id;
|
|
}
|
|
|
|
function TranscriptTurnSegmentView({
|
|
agentAvatarUrl,
|
|
agentName,
|
|
agentPubkey,
|
|
profiles,
|
|
segment,
|
|
}: AgentTranscriptIdentityProps & {
|
|
profiles?: UserProfileLookup;
|
|
segment: TranscriptTurnSegment;
|
|
}) {
|
|
if (segment.kind === "prompt") {
|
|
return (
|
|
<TurnPromptBlock
|
|
context={segment.context}
|
|
profiles={profiles}
|
|
setup={segment.setup}
|
|
user={segment.user}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (segment.kind === "setup") {
|
|
return <TurnSetupStatus items={segment.items} />;
|
|
}
|
|
|
|
if (segment.kind === "summary") {
|
|
return (
|
|
<SameKindSummaryItem
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
profiles={profiles}
|
|
summary={segment.summary}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TranscriptItemRow
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
item={segment.item}
|
|
profiles={profiles}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SameKindSummaryItem({
|
|
agentAvatarUrl,
|
|
agentName,
|
|
agentPubkey,
|
|
profiles,
|
|
summary,
|
|
}: AgentTranscriptIdentityProps & {
|
|
profiles?: UserProfileLookup;
|
|
summary: Extract<TranscriptTurnSegment, { kind: "summary" }>["summary"];
|
|
}) {
|
|
const groupedFileEditDiffs = React.useMemo(
|
|
() =>
|
|
summary.renderClass === "file-edit"
|
|
? getGroupedFileEditDiffs(summary.items)
|
|
: [],
|
|
[summary.items, summary.renderClass],
|
|
);
|
|
const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs);
|
|
const expandsToToolItems = summary.items.every(
|
|
(item) => item.type === "tool",
|
|
);
|
|
|
|
return (
|
|
<ActivityRow
|
|
className="flex flex-col gap-0.5"
|
|
openToneScope="summary"
|
|
testId="transcript-same-kind-summary"
|
|
title={formatTranscriptTimestampTitle(summary.timestamp)}
|
|
>
|
|
<ToolRunSummaryLabel label={summary.label} stats={groupedFileEditStats} />
|
|
<ActivityRowContent
|
|
className={cn(
|
|
"flex flex-col",
|
|
expandsToToolItems ? "gap-0.5" : "gap-1 pl-5",
|
|
)}
|
|
>
|
|
{expandsToToolItems
|
|
? summary.items.map((item) => (
|
|
<TranscriptItemView
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
item={item}
|
|
key={item.id}
|
|
profiles={profiles}
|
|
/>
|
|
))
|
|
: summary.items.map((item) => (
|
|
<p
|
|
className="truncate text-xs text-muted-foreground"
|
|
key={item.id}
|
|
>
|
|
{item.type === "tool"
|
|
? item.descriptor.preview || item.descriptor.label
|
|
: item.title}
|
|
</p>
|
|
))}
|
|
</ActivityRowContent>
|
|
</ActivityRow>
|
|
);
|
|
}
|
|
|
|
function getGroupedFileEditDiffs(items: TranscriptItem[]): FileEditDiff[] {
|
|
return items.flatMap((item) => {
|
|
if (item.type !== "tool" || item.isError) {
|
|
return [];
|
|
}
|
|
|
|
const diff = buildCompactToolSummary(item).fileEditDiff;
|
|
return diff && hasFileEditLineDiff(diff) ? [diff] : [];
|
|
});
|
|
}
|
|
|
|
function summarizeFileEditDiffs(
|
|
diffs: FileEditDiff[],
|
|
): ActivityRowStats | null {
|
|
if (diffs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return diffs.reduce(
|
|
(stats, diff) => ({
|
|
additions: stats.additions + diff.additions,
|
|
deletions: stats.deletions + diff.deletions,
|
|
}),
|
|
{ additions: 0, deletions: 0 },
|
|
);
|
|
}
|
|
|
|
function ToolRunSummaryLabel({
|
|
label,
|
|
stats,
|
|
}: {
|
|
label: string;
|
|
stats?: ActivityRowStats | null;
|
|
}) {
|
|
const parts = splitActivityRowLabel(label);
|
|
|
|
if (!parts) {
|
|
return <span className="truncate text-sm font-medium">{label}</span>;
|
|
}
|
|
|
|
return (
|
|
<ActivityRowLabel
|
|
object={parts.object}
|
|
openToneScope="summary"
|
|
stats={stats}
|
|
verb={parts.verb}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function TurnPromptBlock({
|
|
context,
|
|
profiles,
|
|
setup,
|
|
user,
|
|
}: {
|
|
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
|
profiles?: UserProfileLookup;
|
|
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
|
user: Extract<TranscriptItem, { type: "message" }>;
|
|
}) {
|
|
return (
|
|
<div data-testid="transcript-prompt-bundle">
|
|
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
|
|
<div className="mb-1 flex flex-wrap gap-1">
|
|
<TranscriptAcpSourceBadge source="session/prompt:user" />
|
|
{context ? (
|
|
<TranscriptAcpSourceBadge source="session/prompt:context" />
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<PromptUserMessage
|
|
context={context}
|
|
item={user}
|
|
profiles={profiles}
|
|
setup={setup}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PromptUserMessage({
|
|
context = null,
|
|
item,
|
|
profiles,
|
|
setup = [],
|
|
}: {
|
|
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
|
item: Extract<TranscriptItem, { type: "message" }>;
|
|
profiles?: UserProfileLookup;
|
|
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
|
}) {
|
|
const [contextOpen, setContextOpen] = React.useState(false);
|
|
|
|
return (
|
|
<>
|
|
<UserMessageBubble
|
|
bubbleClassName="p-2.5"
|
|
footer={
|
|
<TurnSetupFooter
|
|
context={context}
|
|
contextOpen={contextOpen}
|
|
items={setup}
|
|
messageLink={getTranscriptMessageLink(item)}
|
|
onContextOpenChange={setContextOpen}
|
|
timestamp={item.timestamp}
|
|
/>
|
|
}
|
|
item={item}
|
|
profiles={profiles}
|
|
/>
|
|
<PromptContextDialog
|
|
context={context}
|
|
onOpenChange={setContextOpen}
|
|
open={contextOpen}
|
|
setup={setup}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function PromptContextSections({
|
|
className,
|
|
sections,
|
|
}: {
|
|
className?: string;
|
|
sections: PromptSection[];
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn("space-y-3", className)}
|
|
data-testid="transcript-prompt-context-sections"
|
|
>
|
|
{sections.map((section) => (
|
|
<PromptContextSectionAccordion
|
|
key={`${section.title}:${section.body.slice(0, 48)}`}
|
|
section={section}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PromptContextSectionAccordion({
|
|
section,
|
|
}: {
|
|
section: PromptSection;
|
|
}) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const body = section.body.trim();
|
|
|
|
return (
|
|
<article className="overflow-hidden rounded-2xl bg-muted/40">
|
|
<button
|
|
aria-expanded={open}
|
|
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
|
|
onClick={() => setOpen((value) => !value)}
|
|
type="button"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div
|
|
className={cn(
|
|
"text-sm font-semibold text-foreground",
|
|
!open && "line-clamp-2",
|
|
)}
|
|
>
|
|
{section.title}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"mt-1 text-xs leading-5 text-foreground/70",
|
|
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
|
|
)}
|
|
>
|
|
{body.length > 0 ? (
|
|
body
|
|
) : (
|
|
<span className="italic text-foreground/50">No metadata.</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ChevronDown
|
|
className={cn(
|
|
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
|
open && "rotate-180",
|
|
)}
|
|
/>
|
|
</div>
|
|
</button>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function PromptContextDialog({
|
|
context,
|
|
onOpenChange,
|
|
open,
|
|
setup,
|
|
}: {
|
|
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
|
onOpenChange: (open: boolean) => void;
|
|
open: boolean;
|
|
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
|
}) {
|
|
if (!open || !context || context.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>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} />
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function formatPromptSetupSummary(
|
|
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
|
|
) {
|
|
const label = formatTurnSetupLabel(items);
|
|
const detail = turnSetupDetail(items);
|
|
return [label, detail].filter(Boolean).join(" · ");
|
|
}
|
|
|
|
function TurnSetupFooter({
|
|
context = null,
|
|
contextOpen = false,
|
|
items,
|
|
messageLink = null,
|
|
onContextOpenChange,
|
|
showTimestamp = true,
|
|
timestamp,
|
|
}: {
|
|
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
|
contextOpen?: boolean;
|
|
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
|
messageLink?: { channelId: string; messageId: string } | null;
|
|
onContextOpenChange?: (open: boolean) => void;
|
|
showTimestamp?: boolean;
|
|
timestamp: string;
|
|
}) {
|
|
const label = formatTurnSetupLabel(items);
|
|
const detail = turnSetupDetail(items);
|
|
const tooltipText = [label, detail].filter(Boolean).join(" · ");
|
|
const showSetup = items.length > 0;
|
|
const showContext = context != null && context.sections.length > 0;
|
|
|
|
if (!showSetup && !showContext) {
|
|
return showTimestamp ? (
|
|
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
|
|
) : null;
|
|
}
|
|
|
|
const contextToggle = showContext ? (
|
|
<Toggle
|
|
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
|
|
data-testid="transcript-prompt-context-toggle"
|
|
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
|
|
onPressedChange={onContextOpenChange}
|
|
pressed={contextOpen}
|
|
size="xs"
|
|
variant="ghost"
|
|
>
|
|
<CheckCheck aria-hidden="true" />
|
|
</Toggle>
|
|
) : null;
|
|
|
|
return (
|
|
<div
|
|
className="flex items-center gap-1.5 text-muted-foreground/80"
|
|
data-testid="transcript-turn-setup"
|
|
>
|
|
{showContext && showSetup ? contextToggle : null}
|
|
{!showContext && showSetup ? (
|
|
<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>
|
|
) : null}
|
|
{showContext && !showSetup ? contextToggle : null}
|
|
{showTimestamp ? (
|
|
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getTranscriptMessageLink(
|
|
item: Extract<TranscriptItem, { type: "message" }>,
|
|
) {
|
|
if (!item.channelId || !item.messageId) return null;
|
|
return {
|
|
channelId: item.channelId,
|
|
messageId: item.messageId,
|
|
};
|
|
}
|
|
|
|
function TranscriptItemRow({
|
|
agentAvatarUrl,
|
|
agentName,
|
|
agentPubkey,
|
|
item,
|
|
profiles,
|
|
}: AgentTranscriptIdentityProps & {
|
|
item: TranscriptItem;
|
|
profiles?: UserProfileLookup;
|
|
}) {
|
|
return (
|
|
<div key={item.id}>
|
|
{SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? (
|
|
<TranscriptAcpSourceBadge source={item.acpSource} />
|
|
) : null}
|
|
<TranscriptItemView
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
item={item}
|
|
profiles={profiles}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TurnSetupStatus({
|
|
items,
|
|
}: {
|
|
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
|
}) {
|
|
const timestamp = turnSetupTimestamp(items);
|
|
if (items.length === 0 || !timestamp) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="rounded-md px-2"
|
|
title={formatTranscriptTimestampTitle(timestamp)}
|
|
>
|
|
<TurnSetupFooter
|
|
items={items}
|
|
showTimestamp={false}
|
|
timestamp={timestamp}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const TranscriptItemView = React.memo(function TranscriptItemView({
|
|
agentAvatarUrl,
|
|
agentName,
|
|
agentPubkey,
|
|
item,
|
|
profiles,
|
|
}: AgentTranscriptIdentityProps & {
|
|
item: TranscriptItem;
|
|
profiles?: UserProfileLookup;
|
|
}) {
|
|
return (
|
|
<TranscriptActivityItem
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
item={item}
|
|
profiles={profiles}
|
|
/>
|
|
);
|
|
});
|