Files
buzz/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 5d82f93bc3 fix(desktop): scope observer feed by channel and add session-boundary dividers
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>
2026-07-08 15:02:44 -04:00

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}
/>
);
});