From 7cec752d7a0f1acc9bb2e71e16453b339ac5895c Mon Sep 17 00:00:00 2001 From: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 18:57:17 -0700 Subject: [PATCH 1/5] feat(desktop): add conversation transcript variant for focus-mode agent sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** feature **User Impact:** In the wide/standalone agent session panel the observer transcript now reads like a conversation — the human's prompt as a right-aligned bubble, the agent's reply as unboxed prose at a comfortable measure, reasoning behind a "Thinking…/Thought for Ns" disclosure, the plan as a live checklist, and session/status noise receded to quiet dividers. **Problem:** The transcript had two presentations — the dense `default` activity feed and `compactPreview` — both tuned for a narrow panel. Read at full width they are a scan surface, not something you read a turn in. **Solution:** A third, purely additive `conversation` variant, threaded through `ManagedAgentSessionPanel` and `AgentSessionThreadPanel` as an optional prop with a width-based default. Tool items deliberately route through the same `ToolActivity` presenter as `default` so the parallel tool-chain-card work merges cleanly, and the drawer/shell is untouched. The byte-for-byte guarantee for the existing variants is enforced by a captured-markup fixture, not by inspection: the baseline was produced by mounting the same transcript on unmodified code, and the test fails on any drift in `default` or `compactPreview` output. Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- .../ui/AgentSessionTranscriptChrome.tsx | 358 +++++++++++++++ ...nTranscriptList.conversation.baseline.json | 4 + ...essionTranscriptList.conversation.test.mjs | 387 +++++++++++++++++ .../agents/ui/AgentSessionTranscriptList.tsx | 406 +++--------------- .../ConversationDivider.tsx | 42 ++ .../LifecycleActivity.tsx | 39 +- .../activityRenderClasses/MessageActivity.tsx | 12 +- .../ui/activityRenderClasses/PlanActivity.tsx | 135 +++++- .../activityRenderClasses/ThoughtActivity.tsx | 91 +++- .../UserMessageBubble.tsx | 37 +- .../ui/agentSessionConversationMeta.test.mjs | 239 +++++++++++ .../agents/ui/agentSessionConversationMeta.ts | 115 +++++ .../agents/ui/agentSessionPlanChecklist.ts | 54 +++ .../ui/agentSessionTranscriptContext.ts | 46 +- .../ui/agentSessionTranscriptVariantChoice.ts | 45 ++ .../channels/ui/AgentSessionThreadPanel.tsx | 15 + 16 files changed, 1662 insertions(+), 363 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentSessionTranscriptChrome.tsx create mode 100644 desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.baseline.json create mode 100644 desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs create mode 100644 desktop/src/features/agents/ui/activityRenderClasses/ConversationDivider.tsx create mode 100644 desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionConversationMeta.ts create mode 100644 desktop/src/features/agents/ui/agentSessionPlanChecklist.ts create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptVariantChoice.ts diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptChrome.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptChrome.tsx new file mode 100644 index 0000000000..4cb6838bd4 --- /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 ( +