+ {isConversation ? (
+ // berd labels every agent turn with a small identity row above the
+ // prose — 20px round avatar + name at `text-xs`, `mb-0.5`, `gap-1`
+ // (MessageBubble.tsx:961-981). Without it the reply reads as
+ // unattributed body text in a full-cover view, which was the largest
+ // single divergence from berd. Only the conversation variant gets it:
+ // the other variants' markup is pinned by the byte-for-byte fixture.
+
+ {/*
+ * The avatar is decorative here: `UserAvatar` exposes either an
+ * image named `${displayName} avatar` or its fallback initials, and
+ * the agent's name already follows as visible text, so an
+ * unhidden avatar makes a screen reader announce the same identity
+ * twice for every agent turn. The visible name is the row's single
+ * accessible identity. Hidden at this call site rather than by
+ * teaching the shared `UserAvatar` a decorative mode: other rows
+ * that pair an avatar with adjacent name text (for example
+ * `ForumPostCard.tsx:91-99`) have the same shape and would want the
+ * same treatment, but changing the shared component's accessible
+ * name affects all 45 of its call sites and is not this PR's scope.
+ *
+ * `size="xs"` is already 20px (`h-5 w-5`) in UserAvatar.
+ */}
+
+
+
+
+ {resolvedAgentName}
+
+
+ ) : null}
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx
index 334a1b0807..59c1cca1b3 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx
@@ -1,3 +1,6 @@
+import { Check, ListChecks, Loader } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import {
ActivityRow,
@@ -5,6 +8,12 @@ import {
ActivityRowLabel,
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
+import {
+ formatPlanChecklistProgress,
+ parsePlanChecklist,
+ type PlanChecklistEntry,
+} from "../agentSessionPlanChecklist";
+import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { ActivityRenderClassItemProps } from "./types";
@@ -16,14 +25,27 @@ export function PlanActivity(props: ActivityRenderClassItemProps) {
return null;
}
- if (props.item.isUpdate) {
+ return
;
+}
+
+function PlanItem({
+ item,
+}: {
+ item: Extract
;
+}) {
+ const variant = useAgentSessionTranscriptVariant();
+
+ // Plan-update markers stay a one-line "Updated plan · 3/5 complete" row in
+ // every variant: in focus mode the card below already carries current state,
+ // so the marker is a timeline note and must not compete with it.
+ if (item.isUpdate) {
return (
}
+ object={}
openToneScope="none"
verb="Updated"
/>
@@ -31,22 +53,125 @@ export function PlanActivity(props: ActivityRenderClassItemProps) {
);
}
+ if (variant === "conversation") {
+ return ;
+ }
+
return (
);
}
+/**
+ * Focus-mode plan: a checklist card rather than a collapsed markdown row.
+ *
+ * The plan item is mutated in place by the transcript reducer (`replaceItem`
+ * keeps the same item id), so this card is the one surface that shows current
+ * plan state — re-rendering it as entries flip is the "updates in place"
+ * behavior; no local state is involved.
+ *
+ * Adapters that send free-form plan text instead of ACP `entries[]` produce no
+ * checklist lines, so the card falls back to the markdown body.
+ */
+function ConversationPlanCard({
+ item,
+}: {
+ item: Extract;
+}) {
+ const text = item.text.trim();
+ const checklist = parsePlanChecklist(text);
+ const progress = formatPlanChecklistProgress(checklist);
+
+ return (
+
+
+
+ Plan
+ {progress ? (
+ <>
+ ·
+ {progress}
+ >
+ ) : null}
+
+ {checklist.entries.length > 0 ? (
+
+ {checklist.entries.map((entry, index) => (
+
+ ))}
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
+
+function PlanChecklistRow({ entry }: { entry: PlanChecklistEntry }) {
+ return (
+
+
+ {entry.status === "completed" ? (
+
+ ) : entry.status === "in_progress" ? (
+
+ ) : (
+
+ )}
+
+
+ {entry.label}
+
+
+ {entry.status === "completed"
+ ? " (completed)"
+ : entry.status === "in_progress"
+ ? " (in progress)"
+ : " (pending)"}
+
+
+ );
+}
+
function PlanUpdateLabelObject({ text }: { text: string }) {
return (
<>
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx
index 8dfaea86ef..7c66e02750 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx
@@ -16,17 +16,30 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) {
return null;
}
+ return ;
+}
+
+/**
+ * The `default`/`compactPreview` thought row.
+ *
+ * The `conversation` variant does not reach this presenter: focus mode renders
+ * reasoning as a row on the work block's rail (`AgentSessionWorkBlock`), so the
+ * separate per-thought disclosure that used to live here is gone rather than
+ * duplicated. Its "Thought for Ns" label logic moved with it.
+ */
+function ThoughtItem({
+ item,
+}: {
+ item: Extract;
+}) {
return (
-
+
-
+
);
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx
index 181e4febf5..02980f6111 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx
@@ -33,24 +33,48 @@ export function UserMessageBubble({
const { goChannel } = useAppNavigation();
const { openProfilePanel } = useProfilePanel();
const isCompactPreview = variant === "compactPreview";
- const shouldClampBubble = !isCompactPreview;
+ const isConversation = variant === "conversation";
+ // Focus mode shows the whole prompt: the reader is here to read the turn, and
+ // the channel-context affordance already lives in the footer, so clamping the
+ // bubble would hide the one thing they came for.
+ const shouldClampBubble = !isCompactPreview && !isConversation;
const [bubbleRef, hasBubbleOverflow] =
useTranscriptBubbleOverflow(shouldClampBubble);
const text = item.text.trim();
+ // The bubble stays a link back to the originating channel message in both
+ // polished variants; only the dense preview drops it.
const messageLink =
- shouldClampBubble && item.channelId && item.messageId
+ !isCompactPreview && item.channelId && item.messageId
? { channelId: item.channelId, messageId: item.messageId }
: null;
const authorProfile = item.authorPubkey
? profiles?.[item.authorPubkey.toLowerCase()]
: null;
- const authorLabel = item.authorPubkey
+ // The other variants seed the avatar from `resolveUserLabel(…, fallbackName:
+ // item.title)`, whose last resort is the prompt item's title. That title
+ // describes the *trigger* that started the turn ("@Mention", "Prompt", "Buzz
+ // event"), never a person — harmless when it only picks avatar initials, so
+ // this chain is left exactly as it was for `default`/`compactPreview`.
+ const triggerSeededLabel = item.authorPubkey
? resolveUserLabel({
pubkey: item.authorPubkey,
fallbackName: item.title,
profiles,
})
: item.title || "User";
+ // Focus mode promotes the author to displayed text, where a trigger title
+ // would read as a false identity — an unresolved sender showing up as
+ // "@Mention". Identity resolution here therefore stops at the profile
+ // (display name, then NIP-05 handle, then the truncated pubkey): a truncated
+ // pubkey is a real, if terse, identity, and it keeps the utterance attributed
+ // in a full-cover view. `item.title` stays available as trigger chrome in the
+ // footer; it is never a name. Only when the item carries no author at all does
+ // the row fall back to a generic placeholder.
+ const authorLabel = isConversation
+ ? item.authorPubkey
+ ? resolveUserLabel({ pubkey: item.authorPubkey, profiles })
+ : "User"
+ : triggerSeededLabel;
const handleBubbleClick = React.useCallback(
(event: React.MouseEvent) => {
if (!messageLink || isNestedInteractiveTarget(event)) return;
@@ -130,9 +154,23 @@ export function UserMessageBubble({
className={cn(
"group relative flex min-w-0 flex-1 flex-col items-end gap-1",
isCompactPreview && "items-start",
+ // berd caps the user turn at a fixed measure, not a percentage of the
+ // column (`--chat-user-message-max-width: 640px`,
+ // MessageBubble.tsx:956): a percentage keeps re-wrapping the prompt as
+ // the cover width changes, while a fixed measure holds one stable
+ // reading line length. `max-w-prompt-bubble` carries the 640px token.
+ isConversation && "max-w-prompt-bubble flex-initial",
className,
)}
>
+ {isConversation ? (
+
+ {authorLabel}
+
+ ) : null}
@@ -156,7 +209,17 @@ export function UserMessageBubble({
) : null}
{messageLink ? : null}
- {footer}
+ {isConversation && footer ? (
+ // Timestamp/context row is chrome, not content: focus mode keeps it
+ // out of the reading rhythm until the row is hovered or
+ // keyboard-focused. Other variants render `footer` bare so their
+ // markup is unchanged.
+
+ {footer}
+
+ ) : (
+ footer
+ )}