diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptChrome.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptChrome.tsx new file mode 100644 index 00000000000..4cb6838bd4a --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptChrome.tsx @@ -0,0 +1,358 @@ +import * as React from "react"; +import { CheckCheck, Clock, Radio } from "lucide-react"; + +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Toggle } from "@/shared/ui/toggle"; +import type { PromptSection, TranscriptItem } from "./agentSessionTypes"; +import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion"; +import { useAgentSessionTranscriptVariant } from "./agentSessionTranscriptContext"; +import { + formatTurnSetupLabel, + turnSetupDetail, + turnSetupTimestamp, +} from "./agentSessionTranscriptGrouping"; +import { formatTranscriptTimestampTitle } from "./agentSessionUtils"; +import { ConversationDivider } from "./activityRenderClasses/ConversationDivider"; +import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp"; +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. + */ +export const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource(); + +function shouldShowTranscriptAcpSource() { + // `import.meta.env` is bundler-provided; guard it so this module stays + // importable from node-based render tests (same pattern as + // features/onboarding/devFreshOnboarding.ts). + 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 TranscriptAcpSourceBadge({ source }: { source: string }) { + return ( + + {source} + + ); +} + +export function TurnPromptBlock({ + context, + profiles, + setup, + user, +}: { + context: Extract | null; + profiles?: UserProfileLookup; + setup: Extract[]; + user: Extract; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE ? ( +
+ + {context ? ( + + ) : null} +
+ ) : null} + +
+ ); +} + +function PromptUserMessage({ + context = null, + item, + profiles, + setup = [], +}: { + context?: Extract | null; + item: Extract; + profiles?: UserProfileLookup; + setup?: Extract[]; +}) { + const variant = useAgentSessionTranscriptVariant(); + const [contextOpen, setContextOpen] = React.useState(false); + const contextSections = React.useMemo( + () => [...(context?.sections ?? [])], + [context], + ); + + return ( + <> + 0} + items={setup} + messageLink={getTranscriptMessageLink(item)} + onContextOpenChange={setContextOpen} + timestamp={item.timestamp} + /> + } + item={item} + profiles={profiles} + /> + + + ); +} + +function PromptContextDialog({ + onOpenChange, + open, + sections, + setup, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; + sections: PromptSection[]; + setup: Extract[]; +}) { + if (!open || sections.length === 0) { + return null; + } + + const setupText = formatPromptSetupSummary(setup); + + return ( + + +
+ + Prompt context + {setupText ? ( +
+ + {setupText} +
+ ) : null} +
+
+ +
+
+
+
+ ); +} + +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; +}) { + 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 ? ( + + ) : null; + } + + return ( +
+ {showContext ? ( + + + ) : ( + + + {tooltipText} + + )} + {showTimestamp ? ( + + ) : null} +
+ ); +} + +export function getTranscriptMessageLink( + item: Extract, +) { + if (!item.channelId || !item.messageId) return null; + return { + channelId: item.channelId, + messageId: item.messageId, + }; +} + +export function TurnSetupStatus({ + items, +}: { + items: Extract[]; +}) { + const variant = useAgentSessionTranscriptVariant(); + const timestamp = turnSetupTimestamp(items); + if (items.length === 0 || !timestamp) { + return null; + } + + // Focus mode recedes turn setup to a quiet centered divider: the checks-icon + // summary is ingress plumbing, not something a reader judges the turn by. + if (variant === "conversation") { + return ( + + ); + } + + return ( +
+ +
+ ); +} + +/** + * Horizontal rule rendered between session runs in the observer transcript. + * + * Three label states (based on live-frame observation, not harness affinity): + * - `"current"` — most recent session observed via the live relay subscription. + * - `"most-recent"` — newest visible session with no matching live frames + * (loaded from archive or session ended before observation). + * - `"earlier"` — an older session preceding the most-recent one. + */ +export function SessionBoundaryDivider({ + labelState, + sessionStartTimestamp, +}: { + labelState: "current" | "most-recent" | "earlier"; + sessionStartTimestamp: string; +}) { + const variant = useAgentSessionTranscriptVariant(); + const label = + labelState === "current" + ? "Latest live-observed session" + : labelState === "most-recent" + ? "Most recent observed session" + : "Earlier observed session"; + const formattedDate = new Date(sessionStartTimestamp).toLocaleString(); + + if (variant === "conversation") { + return ( +