From cd2037e05146d9364078b32bbfe0561510d1d4af Mon Sep 17 00:00:00 2001 From: ss-dev-00 Date: Wed, 26 Aug 2026 15:26:27 -0700 Subject: [PATCH] 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 Slice B. A third, purely additive `conversation` transcript variant for the full-cover reading surface: 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. The transcript previously had two presentations — the dense `default` activity feed and `compactPreview` — both tuned for a 380px panel. Read at full width they are a scan surface, not something you read a turn in. Threaded through `ManagedAgentSessionPanel` and `AgentSessionThreadPanel` as an optional prop. Tool items deliberately route through the same `ToolActivity` presenter as `default`, which is what lets the tool-chain card work in slice C land on top without conflict. The byte-for-byte guarantee for the existing two variants is enforced by a captured-markup fixture rather than by inspection: the baseline was produced by mounting the same transcript on unmodified code, so the test fails on any drift in `default` or `compactPreview` output. Three defects found and fixed while building it, each with a non-vacuous test: - The author row resolved its name through `resolveUserLabel`'s `fallbackName: item.title`, whose value describes the *trigger* that started the turn ("@Mention", "Prompt", a title-cased Buzz event kind) and is never an identity. Harmless where it only seeded avatar initials; the conversation author row promotes it to displayed text, so an unresolved sender rendered as if "@Mention" were their name. Identity now stops at the profile — display name, NIP-05 handle, truncated pubkey — and the title stays as trigger chrome in the footer. The existing test was asserting the bug. - The captured-markup fixture embeds formatted timestamps, so it was zone-sensitive; pinning `TZ` then exposed that it is locale-sensitive too. Assigning `process.env.LANG` at runtime does not fix it — node resolves its default locale once at startup, unlike `TZ`, which `Date` reads lazily. Both formatting surfaces the render path reaches are overridden, including `Date.prototype.toLocale*`, which does not route through `Intl.DateTimeFormat`. `Intl.DateTimeFormat` must be replaced with a plain function, not an arrow: the render path calls it with `new`. Squashed from ss-dev-01/conversation-variant (#6538) at 5e3faf443. Co-authored-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz> Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen Signed-off-by: ss-dev-00 --- .../ui/AgentSessionTranscriptChrome.tsx | 358 +++++++ ...nTranscriptList.conversation.baseline.json | 4 + ...essionTranscriptList.conversation.test.mjs | 889 ++++++++++++++++++ .../agents/ui/AgentSessionTranscriptList.tsx | 406 ++------ .../ConversationDivider.tsx | 42 + .../LifecycleActivity.tsx | 39 +- .../activityRenderClasses/MessageActivity.tsx | 12 +- .../ui/activityRenderClasses/PlanActivity.tsx | 135 ++- .../activityRenderClasses/ThoughtActivity.tsx | 112 ++- .../UserMessageBubble.tsx | 57 +- .../ui/agentSessionConversationMeta.test.mjs | 267 ++++++ .../agents/ui/agentSessionConversationMeta.ts | 120 +++ .../agents/ui/agentSessionPlanChecklist.ts | 54 ++ .../ui/agentSessionTranscriptContext.ts | 46 +- .../channels/ui/AgentSessionThreadPanel.tsx | 12 + 15 files changed, 2189 insertions(+), 364 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 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 ( +