mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
Two observer-feed defects fixed in a single PR: Slice 1 — channel-scoped archive (index at ingest + idempotent backfill) The archive read path queried all owner_p kind 24200 rows for an identity, then relied on a display-time scopeByChannel filter. When the filter was missing or skipped, frames from other channels leaked into the current channel's observer feed. Fix: add an observer_channel_index table that maps (identity, relay, event_id) to channel_id. Three new Tauri commands drive this: - read_archived_observer_events_for_channel: channel-scoped paginated read via the index (replaces the raw owner_p scan in useLoadArchivedObserverEvents) - index_observer_channel_id: TS calls this after decrypting events to write index entries - read_unindexed_observer_rows: returns all owner_p kind 24200 rows not yet indexed, for the one-shot idempotent backfill Backfill strategy (choice i, backfill-before-read): a React.useEffect in useLoadArchivedObserverEvents runs once on mount, reads all unindexed rows, decrypts each, and batch-writes index entries. After backfill the channel index is authoritative — the scoped read pages it directly with no zero-match raw-page guard needed. Null/decrypt-failed channelId rows stay unscoped and are hidden from every scoped channel view (Will's ruling: option (a) — display decision only, rows stay on disk). Slice 2 — session-boundary rendering Items rendered flat with no session grouping; archived and live frames interleaved without any visual marker of where one agent session ended and another began, causing users to think archived historical context is still live. Fix: split TranscriptItem[] into contiguous session runs before calling buildTranscriptDisplayBlocks. This also fixes the pendingSystemPrompt single-slot clobber (each run gets its own lifecycle). A new session-boundary display block kind is interleaved between runs; SessionBoundaryDivider renders a horizontal rule with session start time. No boundary emitted for single-session transcripts. Liveness — "current session" gating Track latest-live-session-id per (normalized agent pubkey, channelId) in observerRelayStore, set on the handleRelayObserverEvent path, cleared in resetAgentObserverStore. Label a boundary "current session" only when it is both the newest-visible session AND equals that latest-live id; otherwise label it "most recent observed session". Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
998 lines
29 KiB
TypeScript
998 lines
29 KiB
TypeScript
import * as React from "react";
|
|
import { motion, useReducedMotion } from "motion/react";
|
|
import { CheckCheck, Clock, Radio } from "lucide-react";
|
|
|
|
import {
|
|
useActiveAgentTurns,
|
|
type ActiveTurnSummary,
|
|
} from "@/features/agents/activeAgentTurnsStore";
|
|
import {
|
|
subscribeAgentObserverStore,
|
|
getLatestLiveSessionId,
|
|
} from "@/features/agents/observerRelayStore";
|
|
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 { Toggle } from "@/shared/ui/toggle";
|
|
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
|
|
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 { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
|
|
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
|
|
import {
|
|
ActivityRow,
|
|
ActivityRowContent,
|
|
ActivityRowLabel,
|
|
type ActivityRowStats,
|
|
splitActivityRowCountedObject,
|
|
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 { shouldShowTranscriptRowTimestamp } from "./agentSessionTranscriptPresentation";
|
|
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
|
|
import { hasFileEditLineDiff } from "./FileEditDiffView";
|
|
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") {
|
|
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,
|
|
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],
|
|
);
|
|
|
|
// Subscribe to the observer relay store so we read the latest-live-session-id
|
|
// reactively. We don't need the full snapshot — only the key for boundary labeling.
|
|
const getLatestLive = React.useCallback(
|
|
() => getLatestLiveSessionId(agentPubkey, channelId),
|
|
[agentPubkey, channelId],
|
|
);
|
|
const latestLiveSessionId = React.useSyncExternalStore(
|
|
subscribeAgentObserverStore,
|
|
getLatestLive,
|
|
);
|
|
|
|
const displayBlocks = React.useMemo(
|
|
() => buildTranscriptDisplayBlocks(items, latestLiveSessionId),
|
|
[items, latestLiveSessionId],
|
|
);
|
|
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;
|
|
|
|
return (
|
|
<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 (
|
|
<motion.div
|
|
className={scrollContainerClassNames}
|
|
layoutScroll
|
|
onScroll={autoTail ? anchoredScroll.onScroll : undefined}
|
|
ref={autoTail ? scrollContainerRef : undefined}
|
|
>
|
|
<div
|
|
aria-label="Live ACP transcript"
|
|
aria-live="polite"
|
|
className={cn(
|
|
"flex w-full flex-col",
|
|
isCompactPreview ? "gap-1" : "gap-4",
|
|
autoTail && "pb-4",
|
|
contentContainerClassName,
|
|
)}
|
|
ref={autoTail ? contentRef : undefined}
|
|
role="log"
|
|
>
|
|
<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>
|
|
</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);
|
|
}
|
|
|
|
// session-boundary dividers are not renderable content in compact view.
|
|
if (block.kind === "session-boundary") {
|
|
return false;
|
|
}
|
|
|
|
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
|
|
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;
|
|
}
|
|
if (block.kind === "session-boundary") {
|
|
return `session-boundary:${block.sessionId}`;
|
|
}
|
|
return `turn:${block.turnId}`;
|
|
}
|
|
|
|
function TranscriptDisplayBlockView({
|
|
agentAvatarUrl,
|
|
agentName,
|
|
agentPubkey,
|
|
block,
|
|
profiles,
|
|
}: AgentTranscriptIdentityProps & {
|
|
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 === "session-boundary") {
|
|
return (
|
|
<SessionBoundaryDivider
|
|
labelState={block.labelState}
|
|
sessionStartTimestamp={block.sessionStartTimestamp}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (block.kind === "single") {
|
|
return (
|
|
<TranscriptItemRow
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
item={block.item}
|
|
profiles={profiles}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
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) => (
|
|
<motion.div
|
|
animate={ROW_ENTER_TO}
|
|
initial={
|
|
animateSegmentEnter && hasCompletedInitialRenderRef.current
|
|
? ROW_ENTER_FROM
|
|
: false
|
|
}
|
|
key={getTurnSegmentKey(block.turnId, segment)}
|
|
transition={ROW_ENTER_SPRING}
|
|
>
|
|
<TranscriptTurnSegmentView
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
profiles={profiles}
|
|
segment={segment}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
|
|
if (segment.kind === "setup") {
|
|
return `turn:${turnId}:setup`;
|
|
}
|
|
if (segment.kind === "prompt") {
|
|
// A turn can hold multiple prompt segments (initial prompt + mid-turn
|
|
// steers), so key on the user message id rather than the bare turn id.
|
|
return `turn:${turnId}:prompt:${segment.user.id}`;
|
|
}
|
|
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}
|
|
systemPrompt={segment.systemPrompt}
|
|
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" || summary.variant === "mixed"
|
|
? getGroupedFileEditDiffs(summary.items)
|
|
: [],
|
|
[summary.items, summary.renderClass, summary.variant],
|
|
);
|
|
const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs);
|
|
const expandsToToolItems = summary.items.every(
|
|
(item) => item.type === "tool",
|
|
);
|
|
const variant = useAgentSessionTranscriptVariant();
|
|
const timestampsEnabled = useTranscriptTimestampsEnabled();
|
|
const showTimestamp = timestampsEnabled && variant !== "compactPreview";
|
|
// Mixed bursts expand to their child segments in original order: raw tool
|
|
// rows plus nested same-kind summaries that joined the burst (which stay
|
|
// expandable to their own child rows).
|
|
const childSegments = summary.segments ?? null;
|
|
|
|
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 || childSegments ? "gap-0.5" : "gap-1 pl-5",
|
|
)}
|
|
>
|
|
{childSegments
|
|
? childSegments.map((child) =>
|
|
child.kind === "summary" ? (
|
|
<SameKindSummaryItem
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
key={child.summary.id}
|
|
profiles={profiles}
|
|
summary={child.summary}
|
|
/>
|
|
) : (
|
|
<TranscriptItemView
|
|
agentAvatarUrl={agentAvatarUrl}
|
|
agentName={agentName}
|
|
agentPubkey={agentPubkey}
|
|
item={child.item}
|
|
key={child.item.id}
|
|
profiles={profiles}
|
|
/>
|
|
),
|
|
)
|
|
: 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>
|
|
{showTimestamp ? (
|
|
<TranscriptRowTimestamp timestamp={summary.timestamp} />
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 animationPreferenceEnabled = useTranscriptAnimationEnabled();
|
|
const parts = splitActivityRowLabel(label);
|
|
|
|
if (!parts) {
|
|
return <span className="truncate text-sm font-medium">{label}</span>;
|
|
}
|
|
|
|
// Streaming bursts grow their count in place ("Ran 16 tool calls" →
|
|
// "Ran 17 tool calls"); rolling the digits odometer-style makes the
|
|
// increment legible. AnimatedCount keeps an sr-only static value and
|
|
// falls back to static text under prefers-reduced-motion.
|
|
const countedObject =
|
|
animationPreferenceEnabled && typeof parts.object === "string"
|
|
? splitActivityRowCountedObject(parts.object)
|
|
: null;
|
|
const object = countedObject ? (
|
|
<>
|
|
<AnimatedCount value={countedObject.count} />
|
|
{countedObject.rest}
|
|
</>
|
|
) : (
|
|
parts.object
|
|
);
|
|
|
|
return (
|
|
<ActivityRowLabel
|
|
object={object}
|
|
openToneScope="summary"
|
|
stats={stats}
|
|
verb={parts.verb}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function TurnPromptBlock({
|
|
context,
|
|
profiles,
|
|
setup,
|
|
systemPrompt,
|
|
user,
|
|
}: {
|
|
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
|
profiles?: UserProfileLookup;
|
|
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
|
systemPrompt: Extract<TranscriptItem, { type: "metadata" }> | null;
|
|
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}
|
|
systemPrompt={systemPrompt}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PromptUserMessage({
|
|
context = null,
|
|
item,
|
|
profiles,
|
|
setup = [],
|
|
systemPrompt = null,
|
|
}: {
|
|
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
|
item: Extract<TranscriptItem, { type: "message" }>;
|
|
profiles?: UserProfileLookup;
|
|
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}
|
|
/>
|
|
<PromptContextDialog
|
|
onOpenChange={setContextOpen}
|
|
open={contextOpen}
|
|
sections={contextSections}
|
|
setup={setup}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function PromptContextDialog({
|
|
onOpenChange,
|
|
open,
|
|
sections,
|
|
setup,
|
|
}: {
|
|
onOpenChange: (open: boolean) => void;
|
|
open: boolean;
|
|
sections: PromptSection[];
|
|
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
|
}) {
|
|
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>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={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({
|
|
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;
|
|
}) {
|
|
const label = formatTurnSetupLabel(items);
|
|
const detail = turnSetupDetail(items);
|
|
const tooltipText = [label, detail].filter(Boolean).join(" · ");
|
|
const showSetup = items.length > 0;
|
|
const showContext = hasContext && onContextOpenChange != null;
|
|
|
|
if (!showSetup && !showContext) {
|
|
return showTimestamp ? (
|
|
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
|
|
) : null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex items-center gap-1.5 text-muted-foreground/80"
|
|
data-testid="transcript-turn-setup"
|
|
>
|
|
{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}
|
|
</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;
|
|
}) {
|
|
const variant = useAgentSessionTranscriptVariant();
|
|
const timestampsEnabled = useTranscriptTimestampsEnabled();
|
|
const showTimestamp = shouldShowTranscriptRowTimestamp(item, {
|
|
enabled: timestampsEnabled,
|
|
variant,
|
|
});
|
|
|
|
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}
|
|
/>
|
|
{showTimestamp ? (
|
|
<TranscriptRowTimestamp
|
|
messageLink={
|
|
item.type === "message" ? getTranscriptMessageLink(item) : null
|
|
}
|
|
timestamp={item.timestamp}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Opt-in per-row timestamp, anchored bottom-left under the row content and
|
|
* styled to match the chat/transcript timestamps.
|
|
*/
|
|
function TranscriptRowTimestamp({
|
|
messageLink = null,
|
|
timestamp,
|
|
}: {
|
|
messageLink?: { channelId: string; messageId: string } | null;
|
|
timestamp: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="mt-0.5 flex justify-start"
|
|
data-testid="transcript-row-timestamp"
|
|
>
|
|
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Horizontal rule rendered between session runs in the observer transcript.
|
|
*
|
|
* Three label states:
|
|
* - `"current"` — live session, agent is actively running in this context.
|
|
* - `"most-recent"` — newest visible session but no live match (archived-only
|
|
* or session ended). Shown as "Most recent observed session"
|
|
* so users know it is history, not active context.
|
|
* - `"earlier"` — an older session. Always labeled with the archive signal.
|
|
*/
|
|
function SessionBoundaryDivider({
|
|
labelState,
|
|
sessionStartTimestamp,
|
|
}: {
|
|
labelState: "current" | "most-recent" | "earlier";
|
|
sessionStartTimestamp: string;
|
|
}) {
|
|
const label =
|
|
labelState === "current"
|
|
? "Current session"
|
|
: labelState === "most-recent"
|
|
? "Most recent observed session — not in current context"
|
|
: "Earlier session — not in current context";
|
|
const formattedDate = new Date(sessionStartTimestamp).toLocaleString();
|
|
return (
|
|
<div
|
|
className="flex items-center gap-2 px-3 py-2"
|
|
data-testid="session-boundary-divider"
|
|
>
|
|
<div className="h-px flex-1 bg-border" />
|
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
{labelState === "current" ? (
|
|
<Radio aria-hidden="true" className="h-3 w-3" />
|
|
) : (
|
|
<Clock aria-hidden="true" className="h-3 w-3" />
|
|
)}
|
|
{label}
|
|
{" · "}
|
|
{formattedDate}
|
|
</span>
|
|
<div className="h-px flex-1 bg-border" />
|
|
</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}
|
|
/>
|
|
);
|
|
});
|