0;
const hasResult = item.result.trim().length > 0;
const canonicalToolName = item.buzzToolName ?? item.toolName;
@@ -57,7 +59,10 @@ export function ToolItem({
[],
);
- if (compactSummary.presentation === "message") {
+ if (
+ compactSummary.presentation === "message" &&
+ transcriptVariant !== "inlineTimeline"
+ ) {
return (
0;
}
@@ -360,7 +360,7 @@ function TranscriptDisplayBlockView({
profiles?: UserProfileLookup;
}) {
const variant = useAgentSessionTranscriptVariant();
- const isCompactPreview = variant === "compactPreview";
+ const isCompactPreview = variant !== "default";
const animationPreferenceEnabled = useTranscriptAnimationEnabled();
const shouldReduceMotion = useReducedMotion();
// Streaming tool calls land as new segments inside the current turn block
@@ -508,7 +508,7 @@ function SameKindSummaryItem({
);
const variant = useAgentSessionTranscriptVariant();
const timestampsEnabled = useTranscriptTimestampsEnabled();
- const showTimestamp = timestampsEnabled && variant !== "compactPreview";
+ const showTimestamp = timestampsEnabled && variant === "default";
// 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).
diff --git a/desktop/src/features/agents/ui/ModelWorkStreamView.tsx b/desktop/src/features/agents/ui/ModelWorkStreamView.tsx
new file mode 100644
index 0000000000..f0e1e16932
--- /dev/null
+++ b/desktop/src/features/agents/ui/ModelWorkStreamView.tsx
@@ -0,0 +1,368 @@
+import * as React from "react";
+import {
+ AlertTriangle,
+ Check,
+ Circle,
+ Database,
+ Eye,
+ ListChecks,
+ LoaderCircle,
+ Send,
+ Sparkles,
+ Wrench,
+ type LucideIcon,
+} from "lucide-react";
+
+import type { UserProfileLookup } from "@/features/profile/lib/identity";
+import { cn } from "@/shared/lib/cn";
+import { AgentSessionTranscriptVariantProvider } from "./agentSessionTranscriptContext";
+import type { TranscriptItem } from "./agentSessionTypes";
+import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
+import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types";
+import {
+ buildModelWorkStream,
+ MODEL_WORK_PHASES,
+ type ModelWorkPhase,
+ type ModelWorkPhaseState,
+ type ModelWorkStep,
+} from "./modelWorkStream";
+
+const PHASE_PRESENTATION: Record<
+ ModelWorkPhase,
+ { icon: LucideIcon; label: string }
+> = {
+ context: { icon: Database, label: "Input" },
+ explore: { icon: Eye, label: "Explore" },
+ decide: { icon: ListChecks, label: "Decide" },
+ act: { icon: Wrench, label: "Act" },
+ deliver: { icon: Send, label: "Deliver" },
+};
+
+export function ModelWorkStream({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
+ isWorking,
+ items,
+ profiles,
+}: AgentTranscriptIdentityProps & {
+ isWorking: boolean;
+ items: TranscriptItem[];
+ profiles?: UserProfileLookup;
+}) {
+ const stream = React.useMemo(
+ () => buildModelWorkStream(items, { isWorking }),
+ [isWorking, items],
+ );
+
+ return (
+
+
+
+
+
+
+ Current focus
+
+
+ {stream.focus}
+
+
+
+
+
+ {formatTotals(stream.totals)}
+
+
+
+
+ {MODEL_WORK_PHASES.map((phase, index) => (
+
+ ))}
+
+
+
+
Agent trace
+
+ {stream.steps.length}{" "}
+ {stream.steps.length === 1 ? "event" : "events"}
+
+
+
+ {stream.steps.map((step, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+function PhaseNode({
+ isLast,
+ phase,
+ state,
+}: {
+ isLast: boolean;
+ phase: ModelWorkPhase;
+ state: ModelWorkPhaseState;
+}) {
+ const presentation = PHASE_PRESENTATION[phase];
+ const Icon = presentation.icon;
+
+ return (
+
+ {!isLast ? (
+
+ ) : null}
+
+ {state === "active" ? (
+
+ ) : state === "complete" ? (
+
+ ) : (
+
+ )}
+
+
+ {presentation.label}
+
+
+ );
+}
+
+function WorkStreamStep({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
+ isLast,
+ profiles,
+ step,
+}: AgentTranscriptIdentityProps & {
+ isLast: boolean;
+ profiles?: UserProfileLookup;
+ step: ModelWorkStep;
+}) {
+ const presentation = PHASE_PRESENTATION[step.phase];
+ const Icon = step.status === "failed" ? AlertTriangle : presentation.icon;
+ const rendersExistingActivity =
+ step.item.type === "tool" || step.item.type === "lifecycle";
+
+ return (
+
+
+ {!isLast ? (
+
+ ) : null}
+
+ {step.status === "active" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {presentation.label}
+
+
+
+ {step.status === "active"
+ ? "Live"
+ : step.status === "failed"
+ ? "Failed"
+ : "Done"}
+
+
+
+ {rendersExistingActivity ? (
+
+ ) : (
+
+
+ {step.label}
+
+ {step.detail ? (
+
+ {step.detail}
+
+ ) : null}
+
+ )}
+
+
+
+ {step.finding ? (
+
+
+
+
+ {step.signalLabel}
+
+ {" · "}
+ {step.finding}
+
+
+ ) : null}
+
+
+ );
+}
+
+function ActualTrace({ step }: { step: ModelWorkStep }) {
+ return (
+
+
+
+ Call
+
+
+ {step.trace.name}
+
+
+ {step.trace.input ? (
+
+ ) : null}
+ {step.trace.output ? (
+
+ ) : null}
+
+ );
+}
+
+function TraceValue({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+function StreamStatus({ isWorking }: { isWorking: boolean }) {
+ return (
+
+ {isWorking ? (
+
+ ) : (
+
+ )}
+ {isWorking ? "Working" : "Complete"}
+
+ );
+}
+
+function formatTotals({
+ actions,
+ findings,
+ inputs,
+}: {
+ actions: number;
+ findings: number;
+ inputs: number;
+}) {
+ return [
+ `${inputs} ${inputs === 1 ? "input" : "inputs"}`,
+ `${findings} ${findings === 1 ? "finding" : "findings"}`,
+ `${actions} ${actions === 1 ? "action" : "actions"}`,
+ ].join(" · ");
+}
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx
index 8c82bb0d6c..edcadb504f 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx
@@ -42,6 +42,7 @@ export function ActivityRow({
testId,
title,
}: ActivityRowProps) {
+ const variant = useAgentSessionTranscriptVariant();
const childArray = React.Children.toArray(children);
const summaryChildren = childArray.filter(
(child) => !isActivityRowContent(child),
@@ -68,6 +69,7 @@ export function ActivityRow({
className,
)}
data-testid={testId}
+ open={variant === "inlineTimeline" && openToneScope === "summary"}
title={title}
>
("default");
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts
index a77a21ff89..f4818b561f 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts
+++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts
@@ -11,7 +11,7 @@ export function shouldShowTranscriptRowTimestamp(
item: TranscriptItem,
options: { enabled: boolean; variant: string },
): boolean {
- if (!options.enabled || options.variant === "compactPreview") {
+ if (!options.enabled || options.variant !== "default") {
return false;
}
if (item.type === "message" && item.role !== "assistant") {
diff --git a/desktop/src/features/agents/ui/modelWorkStream.test.mjs b/desktop/src/features/agents/ui/modelWorkStream.test.mjs
new file mode 100644
index 0000000000..c47bdccc4f
--- /dev/null
+++ b/desktop/src/features/agents/ui/modelWorkStream.test.mjs
@@ -0,0 +1,173 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { buildModelWorkStream } from "./modelWorkStream.ts";
+
+const baseIdentity = {
+ channelId: "channel-1",
+ sessionId: "session-1",
+ turnId: "turn-1",
+};
+
+function tool({
+ id,
+ label,
+ renderClass = "relay-op",
+ result = "",
+ status = "completed",
+ tone = "read",
+ toolName = label,
+}) {
+ return {
+ ...baseIdentity,
+ id,
+ type: "tool",
+ renderClass,
+ descriptor: {
+ action: { verb: label, object: "channel history" },
+ label,
+ preview: "channel history",
+ renderClass,
+ tone,
+ },
+ title: label,
+ toolName,
+ buzzToolName: toolName,
+ status,
+ args: {},
+ result,
+ isError: false,
+ timestamp: "2026-08-05T00:00:00.000Z",
+ startedAt: "2026-08-05T00:00:00.000Z",
+ completedAt: status === "completed" ? "2026-08-05T00:00:01.000Z" : null,
+ };
+}
+
+test("maps context, discovery, decision, action, and delivery in order", () => {
+ const stream = buildModelWorkStream(
+ [
+ {
+ ...baseIdentity,
+ id: "context",
+ type: "metadata",
+ renderClass: "raw-rail",
+ title: "Prompt context",
+ sections: [
+ { title: "Workspace", body: "private instructions" },
+ { title: "Channel history", body: "private channel context" },
+ ],
+ timestamp: "2026-08-05T00:00:00.000Z",
+ },
+ tool({
+ id: "read",
+ label: "Checked",
+ result: JSON.stringify({ messages: [{ id: "1" }, { id: "2" }] }),
+ }),
+ {
+ ...baseIdentity,
+ id: "thought",
+ type: "thought",
+ renderClass: "thought",
+ title: "Analysis",
+ text: "private hidden reasoning must not surface",
+ timestamp: "2026-08-05T00:00:01.000Z",
+ },
+ tool({ id: "edit", label: "Updated", tone: "write" }),
+ tool({ id: "send", label: "Sent", renderClass: "message" }),
+ ],
+ { isWorking: false },
+ );
+
+ assert.deepEqual(
+ stream.steps.map((step) => step.phase),
+ ["context", "explore", "decide", "act", "deliver"],
+ );
+ assert.equal(stream.steps[1]?.finding, "2 messages returned");
+ assert.equal(stream.steps[1]?.signalLabel, "Found");
+ assert.equal(stream.steps[1]?.trace.name, "Checked");
+ assert.equal(stream.steps[1]?.trace.output, "2 messages returned");
+ assert.equal(stream.steps[2]?.label, "Analyzing available signals");
+ assert.equal(
+ stream.steps.some((step) =>
+ `${step.label} ${step.detail}`.includes("private hidden reasoning"),
+ ),
+ false,
+ );
+ assert.equal(stream.steps[4]?.label, "Response delivered");
+ assert.match(stream.focus, /Response delivered/);
+});
+
+test("marks the latest running tool and phase as active", () => {
+ const stream = buildModelWorkStream(
+ [
+ tool({ id: "read", label: "Checked" }),
+ tool({
+ id: "search",
+ label: "Searching",
+ status: "executing",
+ }),
+ ],
+ { isWorking: true },
+ );
+
+ assert.equal(stream.activePhase, "explore");
+ assert.equal(stream.phaseStates.explore, "active");
+ assert.equal(stream.steps.at(-1)?.status, "active");
+ assert.match(stream.focus, /Searching/);
+ assert.equal(stream.steps.at(-1)?.trace.output, "Awaiting result");
+});
+
+test("summarizes structured findings without dumping raw tool output", () => {
+ const stream = buildModelWorkStream(
+ [
+ tool({
+ id: "read",
+ label: "Read",
+ result: JSON.stringify({
+ content: "The launch date is Friday after the final review.",
+ }),
+ }),
+ ],
+ { isWorking: false },
+ );
+
+ assert.equal(
+ stream.steps[0]?.finding,
+ "The launch date is Friday after the final review.",
+ );
+ assert.equal(stream.totals.findings, 1);
+});
+
+test("treats model sampling as a decision and surfaces its selected action", () => {
+ const stream = buildModelWorkStream(
+ [
+ tool({
+ id: "sample",
+ label: "Sampled",
+ result: "Selected action: check_messages.",
+ toolName: "sample_model",
+ }),
+ ],
+ { isWorking: false },
+ );
+ assert.equal(stream.steps[0]?.phase, "decide");
+ assert.equal(stream.steps[0]?.signalLabel, "Chose");
+ assert.equal(stream.steps[0]?.finding, "Selected action: check_messages.");
+ assert.equal(stream.steps[0]?.trace.name, "sample_model");
+});
+
+test("keeps the actual tool arguments alongside the summarized flow", () => {
+ const item = tool({ id: "messages", label: "Checked" });
+ item.args = {
+ channel: "agents",
+ contextMessages: 24,
+ endpoint: "ampersand/glm51",
+ unreadOnly: true,
+ };
+
+ const stream = buildModelWorkStream([item], { isWorking: false });
+ assert.equal(
+ stream.steps[0]?.trace.input,
+ "channel=agents · contextMessages=24 · endpoint=ampersand/glm51 · unreadOnly=true",
+ );
+});
diff --git a/desktop/src/features/agents/ui/modelWorkStream.ts b/desktop/src/features/agents/ui/modelWorkStream.ts
new file mode 100644
index 0000000000..839cbecd91
--- /dev/null
+++ b/desktop/src/features/agents/ui/modelWorkStream.ts
@@ -0,0 +1,414 @@
+import { getSentMessageLink } from "./AgentSessionToolItem/messageLinks";
+import type { TranscriptItem, ToolStatus } from "./agentSessionTypes";
+
+export const MODEL_WORK_PHASES = [
+ "context",
+ "explore",
+ "decide",
+ "act",
+ "deliver",
+] as const;
+
+export type ModelWorkPhase = (typeof MODEL_WORK_PHASES)[number];
+export type ModelWorkStepStatus = "active" | "complete" | "failed";
+
+export type ModelWorkStep = {
+ detail: string | null;
+ finding: string | null;
+ id: string;
+ item: TranscriptItem;
+ label: string;
+ phase: ModelWorkPhase;
+ signalLabel: "Chose" | "Found" | null;
+ status: ModelWorkStepStatus;
+ trace: {
+ input: string | null;
+ name: string;
+ output: string | null;
+ };
+};
+
+export type ModelWorkPhaseState = "active" | "complete" | "idle";
+
+export type ModelWorkStream = {
+ activePhase: ModelWorkPhase | null;
+ focus: string;
+ phaseStates: Record;
+ steps: ModelWorkStep[];
+ totals: {
+ actions: number;
+ findings: number;
+ inputs: number;
+ };
+};
+
+export function buildModelWorkStream(
+ items: readonly TranscriptItem[],
+ options: { isWorking: boolean },
+): ModelWorkStream {
+ const steps = items
+ .filter(isWorkStreamItem)
+ .map((item) => buildModelWorkStep(item, options.isWorking));
+ const latestStep = steps.at(-1) ?? null;
+ const activePhase = options.isWorking ? (latestStep?.phase ?? null) : null;
+ const visitedPhases = new Set(steps.map((step) => step.phase));
+ const phaseStates = Object.fromEntries(
+ MODEL_WORK_PHASES.map((phase) => [
+ phase,
+ phase === activePhase
+ ? "active"
+ : visitedPhases.has(phase)
+ ? "complete"
+ : "idle",
+ ]),
+ ) as Record;
+ const latestFinding = findLast(steps, (step) => Boolean(step.finding));
+ const latestDelivery = findLast(
+ steps,
+ (step) => step.phase === "deliver" && step.status === "complete",
+ );
+ const focusStep = options.isWorking
+ ? latestStep
+ : (latestDelivery ?? latestFinding ?? latestStep);
+
+ return {
+ activePhase,
+ focus: focusStep
+ ? [focusStep.label, focusStep.detail].filter(Boolean).join(" · ")
+ : "Waiting for activity",
+ phaseStates,
+ steps,
+ totals: {
+ actions: steps.filter(
+ (step) => step.phase === "act" || step.phase === "deliver",
+ ).length,
+ findings: steps.filter((step) => Boolean(step.finding)).length,
+ inputs: steps.filter((step) => step.phase === "context").length,
+ },
+ };
+}
+
+function buildModelWorkStep(
+ item: TranscriptItem,
+ isWorking: boolean,
+): ModelWorkStep {
+ const phase = phaseForItem(item);
+ const isLatestActive = isWorking && isItemActive(item);
+
+ if (item.type === "metadata") {
+ const sectionNames = item.sections
+ .map((section) => section.title.trim())
+ .filter(Boolean);
+ return {
+ detail:
+ sectionNames.length > 0
+ ? `${sectionNames.slice(0, 3).join(", ")}${sectionNames.length > 3 ? ` +${sectionNames.length - 3}` : ""}`
+ : null,
+ finding: null,
+ id: item.id,
+ item,
+ label: "Context received",
+ phase,
+ signalLabel: null,
+ status: "complete",
+ trace: {
+ input: `${item.sections.length} context ${item.sections.length === 1 ? "section" : "sections"}`,
+ name: item.acpSource ?? "session/prompt:context",
+ output: null,
+ },
+ };
+ }
+
+ if (item.type === "message") {
+ return {
+ detail: compactText(item.text, 120),
+ finding: null,
+ id: item.id,
+ item,
+ label: item.role === "user" ? "Request received" : "Response prepared",
+ phase,
+ signalLabel: null,
+ status: "complete",
+ trace: {
+ input: compactText(item.text, 160),
+ name:
+ item.acpSource ??
+ (item.role === "user" ? "session/prompt:user" : "assistant/message"),
+ output: null,
+ },
+ };
+ }
+
+ if (item.type === "thought") {
+ return {
+ detail:
+ item.title && !/^(thought|thinking|analysis)$/i.test(item.title)
+ ? item.title
+ : null,
+ finding: null,
+ id: item.id,
+ item,
+ label: "Analyzing available signals",
+ phase,
+ signalLabel: null,
+ status: isLatestActive ? "active" : "complete",
+ trace: {
+ input: null,
+ name: item.acpSource ?? "model/reasoning",
+ output: "Reasoning content remains private",
+ },
+ };
+ }
+
+ if (item.type === "plan") {
+ return {
+ detail: compactText(item.text, 120),
+ finding: null,
+ id: item.id,
+ item,
+ label: item.isUpdate ? "Plan adjusted" : "Plan formed",
+ phase,
+ signalLabel: null,
+ status: isLatestActive ? "active" : "complete",
+ trace: {
+ input: compactText(item.text, 160),
+ name: item.acpSource ?? "model/plan",
+ output: null,
+ },
+ };
+ }
+
+ if (item.type === "lifecycle") {
+ const failed = item.renderClass === "error";
+ return {
+ detail: compactText(item.text, 120),
+ finding: null,
+ id: item.id,
+ item,
+ label: item.title,
+ phase,
+ signalLabel: null,
+ status: failed ? "failed" : isLatestActive ? "active" : "complete",
+ trace: {
+ input: compactText(item.text, 160),
+ name: item.acpSource ?? `session/${item.renderClass}`,
+ output: item.outcome ?? null,
+ },
+ };
+ }
+
+ const isDelivery = phase === "deliver";
+ const failed = item.isError || item.status === "failed";
+ const status = failed
+ ? "failed"
+ : isToolActive(item.status)
+ ? "active"
+ : "complete";
+ const label = isDelivery
+ ? status === "active"
+ ? "Delivering response"
+ : failed
+ ? "Delivery failed"
+ : "Response delivered"
+ : toolLabel(item);
+ const finding =
+ (phase === "explore" || phase === "decide") && status === "complete"
+ ? summarizeToolResult(item.result)
+ : null;
+
+ return {
+ detail: item.descriptor.object ?? item.descriptor.preview ?? null,
+ finding,
+ id: item.id,
+ item,
+ label,
+ phase,
+ signalLabel:
+ finding && phase === "explore"
+ ? "Found"
+ : finding && phase === "decide"
+ ? "Chose"
+ : null,
+ status,
+ trace: {
+ input: summarizeToolArguments(item.args),
+ name: item.buzzToolName ?? item.toolName,
+ output:
+ status === "active"
+ ? "Awaiting result"
+ : summarizeToolResult(item.result),
+ },
+ };
+}
+
+function phaseForItem(item: TranscriptItem): ModelWorkPhase {
+ if (item.type === "metadata") return "context";
+ if (item.type === "message") {
+ return item.role === "user" ? "context" : "deliver";
+ }
+ if (item.type === "thought" || item.type === "plan") return "decide";
+ if (item.type === "lifecycle") {
+ if (item.renderClass === "permission" || item.renderClass === "error") {
+ return "act";
+ }
+ return "context";
+ }
+
+ if (
+ item.descriptor.renderClass === "message" ||
+ getSentMessageLink(item) !== null
+ ) {
+ return "deliver";
+ }
+
+ const canonicalToolName = (item.buzzToolName ?? item.toolName).toLowerCase();
+ if (
+ canonicalToolName === "sample_model" ||
+ canonicalToolName === "reason_with_model" ||
+ canonicalToolName === "query_model"
+ ) {
+ return "decide";
+ }
+
+ const verb = item.descriptor.action?.verb.toLowerCase() ?? "";
+ if (
+ item.descriptor.tone === "read" ||
+ item.renderClass === "file-read" ||
+ item.renderClass === "skill-read" ||
+ item.renderClass === "image" ||
+ /^(checked|found|listed|read|searched|viewed)$/.test(verb)
+ ) {
+ return "explore";
+ }
+
+ return "act";
+}
+
+function isWorkStreamItem(item: TranscriptItem) {
+ if (item.renderClass === "suppressed") return false;
+ if (item.type === "metadata") {
+ return item.acpSource !== "raw_json_rpc";
+ }
+ if (item.type === "lifecycle") {
+ return !/^(session ready|turn started|wire parse error)$/i.test(item.title);
+ }
+ return true;
+}
+
+function isItemActive(item: TranscriptItem) {
+ if (item.type === "tool") return isToolActive(item.status);
+ if (item.type === "lifecycle") {
+ return item.renderClass === "permission" && !item.outcome;
+ }
+ return true;
+}
+
+function isToolActive(status: ToolStatus) {
+ return status === "executing" || status === "pending";
+}
+
+function toolLabel(item: Extract) {
+ const action = item.descriptor.action;
+ if (action) {
+ return [action.verb, action.object].filter(Boolean).join(" ");
+ }
+ return item.descriptor.label || item.title || "Tool call";
+}
+
+function summarizeToolResult(result: string): string | null {
+ const trimmed = result.trim();
+ if (!trimmed) return null;
+
+ try {
+ return compactText(summarizeStructuredValue(JSON.parse(trimmed)), 150);
+ } catch {
+ return compactText(trimmed, 150);
+ }
+}
+
+function summarizeToolArguments(args: Record): string | null {
+ const entries = Object.entries(args);
+ if (entries.length === 0) return null;
+
+ const summary = entries
+ .slice(0, 4)
+ .map(([key, value]) => `${key}=${summarizeArgumentValue(value)}`)
+ .join(" · ");
+ return compactText(
+ `${summary}${entries.length > 4 ? ` · +${entries.length - 4} more` : ""}`,
+ 180,
+ );
+}
+
+function summarizeArgumentValue(value: unknown): string {
+ if (typeof value === "string") return value;
+ if (
+ typeof value === "number" ||
+ typeof value === "boolean" ||
+ value == null
+ ) {
+ return String(value);
+ }
+ if (Array.isArray(value)) return `[${value.length}]`;
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+}
+
+function summarizeStructuredValue(value: unknown): string {
+ if (typeof value === "string") return value;
+ if (Array.isArray(value)) {
+ if (value.length === 0) return "No results returned";
+ const text = value
+ .map((entry) => summarizeStructuredValue(entry))
+ .filter(Boolean)
+ .slice(0, 2)
+ .join(" · ");
+ return text || `${value.length} results returned`;
+ }
+ if (!value || typeof value !== "object") return String(value ?? "");
+
+ const record = value as Record;
+ for (const key of ["messages", "items", "results", "events"]) {
+ const entries = record[key];
+ if (Array.isArray(entries)) {
+ const noun = key === "items" || key === "results" ? "results" : key;
+ return `${entries.length} ${noun} returned`;
+ }
+ }
+ for (const key of ["summary", "text", "content", "output", "message"]) {
+ if (record[key] !== undefined) {
+ const summary = summarizeStructuredValue(record[key]);
+ if (summary) return summary;
+ }
+ }
+
+ const scalarEntries = Object.entries(record).filter(
+ ([, entry]) =>
+ typeof entry === "string" ||
+ typeof entry === "number" ||
+ typeof entry === "boolean",
+ );
+ return scalarEntries
+ .slice(0, 3)
+ .map(([key, entry]) => `${key.replaceAll("_", " ")}: ${String(entry)}`)
+ .join(" · ");
+}
+
+function compactText(text: string, maxLength: number): string | null {
+ const compact = text.replace(/\s+/g, " ").trim();
+ if (!compact) return null;
+ return compact.length > maxLength
+ ? `${compact.slice(0, maxLength - 1).trimEnd()}…`
+ : compact;
+}
+
+function findLast(items: readonly T[], predicate: (item: T) => boolean) {
+ for (let index = items.length - 1; index >= 0; index -= 1) {
+ const item = items[index];
+ if (item !== undefined && predicate(item)) return item;
+ }
+ return undefined;
+}
diff --git a/desktop/src/features/channels/lib/inlineAgentActivity.test.mjs b/desktop/src/features/channels/lib/inlineAgentActivity.test.mjs
new file mode 100644
index 0000000000..b179ae000b
--- /dev/null
+++ b/desktop/src/features/channels/lib/inlineAgentActivity.test.mjs
@@ -0,0 +1,141 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { buildInlineAgentActivityPlacement } from "./inlineAgentActivity.ts";
+
+function toolItem({
+ channelId = "channel-1",
+ id,
+ result = "",
+ turnId = "turn-1",
+ toolName = "check_messages",
+}) {
+ return {
+ id,
+ type: "tool",
+ renderClass: toolName === "send_message" ? "message" : "relay-op",
+ descriptor: {
+ label: toolName,
+ preview: null,
+ renderClass: toolName === "send_message" ? "message" : "relay-op",
+ },
+ title: toolName,
+ toolName,
+ buzzToolName: toolName,
+ status: "completed",
+ args: { channel_id: channelId },
+ result,
+ isError: false,
+ timestamp: "2026-08-04T18:00:00.000Z",
+ startedAt: "2026-08-04T18:00:00.000Z",
+ completedAt: "2026-08-04T18:00:01.000Z",
+ channelId,
+ sessionId: "session-1",
+ turnId,
+ };
+}
+
+test("keeps an active turn at the live timeline tail", () => {
+ const placement = buildInlineAgentActivityPlacement({
+ channelId: "channel-1",
+ isWorking: true,
+ renderedMessageIds: new Set(),
+ transcript: [toolItem({ id: "read-1" })],
+ });
+
+ assert.equal(placement?.anchorMessageId, null);
+ assert.deepEqual(
+ placement?.items.map((item) => item.id),
+ ["read-1"],
+ );
+});
+
+test("anchors the trace before the exact message returned by send_message", () => {
+ const placement = buildInlineAgentActivityPlacement({
+ channelId: "channel-1",
+ isWorking: false,
+ renderedMessageIds: new Set(["message-42"]),
+ transcript: [
+ toolItem({ id: "read-1" }),
+ toolItem({
+ id: "send-1",
+ result: JSON.stringify({ accepted: true, event_id: "message-42" }),
+ toolName: "send_message",
+ }),
+ ],
+ });
+
+ assert.equal(placement?.anchorMessageId, "message-42");
+ assert.deepEqual(
+ placement?.items.map((item) => item.id),
+ ["read-1", "send-1"],
+ );
+});
+
+test("does not leave an unanchored trace behind after a turn completes", () => {
+ const placement = buildInlineAgentActivityPlacement({
+ channelId: "channel-1",
+ isWorking: false,
+ renderedMessageIds: new Set(),
+ transcript: [
+ toolItem({
+ id: "send-1",
+ result: JSON.stringify({ accepted: true, event_id: "message-42" }),
+ toolName: "send_message",
+ }),
+ ],
+ });
+
+ assert.equal(placement, null);
+});
+
+test("keeps safe input context for the flow while excluding assistant output", () => {
+ const placement = buildInlineAgentActivityPlacement({
+ channelId: "channel-1",
+ isWorking: true,
+ renderedMessageIds: new Set(),
+ transcript: [
+ {
+ channelId: "channel-1",
+ id: "context-1",
+ renderClass: "raw-rail",
+ sections: [{ title: "Channel history", body: "private context" }],
+ sessionId: "session-1",
+ timestamp: "2026-08-04T18:00:00.000Z",
+ title: "Prompt context",
+ turnId: "turn-1",
+ type: "metadata",
+ },
+ {
+ channelId: "channel-1",
+ id: "user-1",
+ renderClass: "message",
+ role: "user",
+ sessionId: "session-1",
+ text: "What changed?",
+ timestamp: "2026-08-04T18:00:00.000Z",
+ title: "User",
+ turnId: "turn-1",
+ type: "message",
+ },
+ {
+ channelId: "channel-1",
+ id: "assistant-1",
+ renderClass: "message",
+ role: "assistant",
+ sessionId: "session-1",
+ text: "Here is the answer.",
+ timestamp: "2026-08-04T18:00:01.000Z",
+ title: "Assistant",
+ turnId: "turn-1",
+ type: "message",
+ },
+ toolItem({ id: "read-1" }),
+ ],
+ });
+
+ assert.deepEqual(
+ placement?.items.map((item) => item.id),
+ ["context-1", "user-1", "read-1"],
+ );
+});
diff --git a/desktop/src/features/channels/lib/inlineAgentActivity.ts b/desktop/src/features/channels/lib/inlineAgentActivity.ts
new file mode 100644
index 0000000000..3b15bca6bc
--- /dev/null
+++ b/desktop/src/features/channels/lib/inlineAgentActivity.ts
@@ -0,0 +1,99 @@
+import { getSentMessageLink } from "@/features/agents/ui/AgentSessionToolItem/messageLinks";
+import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes";
+
+export type InlineAgentActivityPlacement = {
+ anchorMessageId: string | null;
+ items: TranscriptItem[];
+};
+
+function findLastMatching(
+ items: readonly T[],
+ predicate: (item: T) => boolean,
+): T | undefined {
+ for (let index = items.length - 1; index >= 0; index -= 1) {
+ const item = items[index];
+ if (item !== undefined && predicate(item)) {
+ return item;
+ }
+ }
+ return undefined;
+}
+
+function isSameTurn(
+ item: TranscriptItem,
+ turnId: string | null,
+ sessionId: string | null,
+) {
+ if (turnId) {
+ return item.turnId === turnId;
+ }
+ return Boolean(sessionId) && item.sessionId === sessionId;
+}
+
+function isInlineActivityItem(item: TranscriptItem) {
+ if (item.type === "message") {
+ return item.role === "user";
+ }
+ if (item.type === "metadata") {
+ return item.acpSource !== "raw_json_rpc";
+ }
+ return item.renderClass !== "raw-rail" && item.renderClass !== "suppressed";
+}
+
+/**
+ * Select the newest channel turn and anchor it before the message published by
+ * send_message. Before that message reaches the timeline, the caller renders
+ * the same trace at the live tail instead.
+ */
+export function buildInlineAgentActivityPlacement({
+ channelId,
+ isWorking,
+ renderedMessageIds,
+ transcript,
+}: {
+ channelId: string;
+ isWorking: boolean;
+ renderedMessageIds: ReadonlySet;
+ transcript: readonly TranscriptItem[];
+}): InlineAgentActivityPlacement | null {
+ const latestTurnItem = findLastMatching(
+ transcript,
+ (item) =>
+ item.channelId === channelId && Boolean(item.turnId ?? item.sessionId),
+ );
+ if (!latestTurnItem) {
+ return null;
+ }
+
+ const items = transcript.filter(
+ (item) =>
+ item.channelId === channelId &&
+ isSameTurn(
+ item,
+ latestTurnItem.turnId ?? null,
+ latestTurnItem.sessionId ?? null,
+ ) &&
+ isInlineActivityItem(item),
+ );
+ if (items.length === 0) {
+ return null;
+ }
+
+ const sentMessage = findLastMatching(
+ items,
+ (item) => item.type === "tool" && getSentMessageLink(item) !== null,
+ );
+ const sentMessageLink =
+ sentMessage?.type === "tool" ? getSentMessageLink(sentMessage) : null;
+ const anchorMessageId =
+ sentMessageLink?.channelId === channelId &&
+ renderedMessageIds.has(sentMessageLink.messageId)
+ ? sentMessageLink.messageId
+ : null;
+
+ if (!isWorking && !anchorMessageId) {
+ return null;
+ }
+
+ return { anchorMessageId, items };
+}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 9e5152edfe..e12f972725 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -36,10 +36,10 @@ import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout"
import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
-import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory";
+import { useChannelMainTimeline } from "@/features/channels/ui/useChannelMainTimeline";
import {
containsWelcomePersonaMention,
WelcomeComposerBanner,
@@ -55,7 +55,6 @@ import { useChannelIntro } from "@/features/channels/ui/useChannelIntro";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel";
-import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages";
import { Button } from "@/shared/ui/button";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import type { TimelineMessage } from "@/features/messages/types";
@@ -397,18 +396,25 @@ export const ChannelPane = React.memo(function ChannelPane({
!isComposerDisabled &&
!isMainDeferredEditPending &&
!isSinglePanelView;
- const hasTypingActivity = typingPubkeys.length > 0;
- // Unified working set for the composer bar: observer-derived turns primary,
- // bot typing fallback (both folded together by agentWorkingSignal). This is
- // what makes the bar show for an agent whose observer stream is live but
- // whose typing signal never arrives — and vice versa.
- const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys(
- activeChannel?.id ?? null,
- );
+ const {
+ composerWorkingBotPubkeys,
+ mainTimelineEntries,
+ messageLeadingContent,
+ trailingContent,
+ visibleMessages,
+ } = useChannelMainTimeline({
+ activeChannel,
+ activityAgents,
+ isHuddleTranscript,
+ messages,
+ onOpenAgentSession,
+ profiles,
+ threadSummaries,
+ });
const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0;
const hasCardMintActivity = useCardMintJobs().length > 0;
const hasComposerBottomActivity =
- hasComposerBotActivity || hasTypingActivity || hasCardMintActivity;
+ hasComposerBotActivity || typingPubkeys.length > 0 || hasCardMintActivity;
const threadComposerBotTypingPubkeys = React.useMemo(() => {
if (!openThreadHeadId) return [];
return botTypingEntries
@@ -432,7 +438,6 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[activeChannel, currentPubkey, profiles],
);
-
const handleWelcomeAddAgent = React.useCallback(() => {
onAddAgent?.({
beforeSend: () =>
@@ -448,13 +453,6 @@ export const ChannelPane = React.memo(function ChannelPane({
onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
});
const channelIntro = isHuddleTranscript ? null : standardChannelIntro;
- const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({
- activeChannel,
- isHuddleTranscript,
- messages,
- profiles,
- threadSummaries,
- });
useRenderScopedReactionHydration({
activeChannel,
mainTimelineEntries,
@@ -669,6 +667,7 @@ export const ChannelPane = React.memo(function ChannelPane({
entranceMessageId={entranceMessageId}
onEntranceMessageComplete={onEntranceMessageComplete}
mainEntries={mainTimelineEntries}
+ messageLeadingContent={messageLeadingContent}
threadSummaries={threadSummaries}
messages={visibleMessages}
firstUnreadMessageId={firstUnreadMessageId}
@@ -697,6 +696,7 @@ export const ChannelPane = React.memo(function ChannelPane({
Boolean(openThreadHeadId)
}
threadUnreadCounts={threadUnreadCounts}
+ trailingContent={trailingContent}
/>
{isNonMemberView ? (
void;
+ profiles?: UserProfileLookup;
+ renderedMessageIds: ReadonlySet
;
+ workingBotPubkeys: string[];
+}): InlineAgentActivity | null {
+ const [view, setView] = React.useState<"flow" | "trace">("flow");
+ const workingSet = React.useMemo(
+ () => new Set(workingBotPubkeys.map((pubkey) => pubkey.toLowerCase())),
+ [workingBotPubkeys],
+ );
+ const workingAgent = React.useMemo(
+ () => agents.find((agent) => workingSet.has(agent.pubkey.toLowerCase())),
+ [agents, workingSet],
+ );
+ const workingAgentPubkey = workingAgent?.pubkey ?? null;
+ const [recentAgent, setRecentAgent] = React.useState<{
+ channelId: string;
+ pubkey: string;
+ } | null>(null);
+ React.useEffect(() => {
+ if (channelId && workingAgentPubkey) {
+ setRecentAgent((current) =>
+ current?.channelId === channelId &&
+ current.pubkey.toLowerCase() === workingAgentPubkey.toLowerCase()
+ ? current
+ : { channelId, pubkey: workingAgentPubkey },
+ );
+ }
+ }, [channelId, workingAgentPubkey]);
+ const selectedAgent = React.useMemo(
+ () =>
+ workingAgent ??
+ agents.find(
+ (agent) =>
+ recentAgent?.channelId === channelId &&
+ agent.pubkey.toLowerCase() === recentAgent.pubkey.toLowerCase(),
+ ) ??
+ (agents.length === 1 ? agents[0] : null),
+ [agents, channelId, recentAgent, workingAgent],
+ );
+ const transcript = useAgentTranscript(
+ Boolean(selectedAgent),
+ selectedAgent?.pubkey,
+ );
+ const isWorking = selectedAgent
+ ? workingSet.has(selectedAgent.pubkey.toLowerCase())
+ : false;
+ const placement = React.useMemo(
+ () =>
+ channelId
+ ? buildInlineAgentActivityPlacement({
+ channelId,
+ isWorking,
+ renderedMessageIds,
+ transcript,
+ })
+ : null,
+ [channelId, isWorking, renderedMessageIds, transcript],
+ );
+
+ if (!channelId || !selectedAgent || !placement) {
+ return null;
+ }
+
+ const avatarUrl =
+ profiles?.[selectedAgent.pubkey.toLowerCase()]?.avatarUrl ?? null;
+ const traceItems = placement.items.filter(
+ (item) => item.type !== "message" && item.type !== "metadata",
+ );
+ const content = (
+
+
+
+
+
+
+
+
+
+
+
+ {view === "flow" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+
+ return { ...placement, content };
+}
+
+function viewButtonClassName(selected: boolean) {
+ return selected
+ ? "inline-flex h-6 items-center gap-1 rounded-[4px] bg-background px-1.5 text-3xs font-semibold text-foreground shadow-xs"
+ : "inline-flex h-6 items-center gap-1 rounded-[4px] px-1.5 text-3xs font-medium text-muted-foreground transition-colors hover:text-foreground";
+}
diff --git a/desktop/src/features/channels/ui/useChannelMainTimeline.ts b/desktop/src/features/channels/ui/useChannelMainTimeline.ts
new file mode 100644
index 0000000000..75eba034cc
--- /dev/null
+++ b/desktop/src/features/channels/ui/useChannelMainTimeline.ts
@@ -0,0 +1,71 @@
+import * as React from "react";
+
+import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
+import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar";
+import { useInlineAgentActivity } from "@/features/channels/ui/InlineAgentActivity";
+import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages";
+import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
+import type { TimelineMessage } from "@/features/messages/types";
+import type { UserProfileLookup } from "@/features/profile/lib/identity";
+import type { Channel } from "@/shared/api/types";
+
+export function useChannelMainTimeline({
+ activeChannel,
+ activityAgents,
+ isHuddleTranscript,
+ messages,
+ onOpenAgentSession,
+ profiles,
+ threadSummaries,
+}: {
+ activeChannel: Channel | null;
+ activityAgents: BotActivityAgent[];
+ isHuddleTranscript: boolean;
+ messages: TimelineMessage[];
+ onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
+ profiles?: UserProfileLookup;
+ threadSummaries?: ReadonlyMap;
+}) {
+ const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys(
+ activeChannel?.id ?? null,
+ );
+ const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({
+ activeChannel,
+ isHuddleTranscript,
+ messages,
+ profiles,
+ threadSummaries,
+ });
+ const renderedMessageIds = React.useMemo(
+ () => new Set(mainTimelineEntries.map((entry) => entry.message.id)),
+ [mainTimelineEntries],
+ );
+ const inlineAgentActivity = useInlineAgentActivity({
+ agents: activityAgents,
+ channelId: activeChannel?.id ?? null,
+ onOpenAgentSession,
+ profiles,
+ renderedMessageIds,
+ workingBotPubkeys: composerWorkingBotPubkeys,
+ });
+ const messageLeadingContent = React.useMemo(
+ () =>
+ inlineAgentActivity?.anchorMessageId
+ ? {
+ [inlineAgentActivity.anchorMessageId]: inlineAgentActivity.content,
+ }
+ : undefined,
+ [inlineAgentActivity],
+ );
+
+ return {
+ composerWorkingBotPubkeys,
+ mainTimelineEntries,
+ messageLeadingContent,
+ trailingContent:
+ inlineAgentActivity && !inlineAgentActivity.anchorMessageId
+ ? inlineAgentActivity.content
+ : null,
+ visibleMessages,
+ };
+}
diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs
index daae41a786..b7a54c946f 100644
--- a/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs
+++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.test.mjs
@@ -37,10 +37,17 @@ const DAY_A = dayAt(2026, 6, 1);
const DAY_B = dayAt(2026, 6, 2);
const DAY_Z = dayAt(2026, 5, 31);
-function keysOf(dayGroups, { leading = undefined, exhausted = false } = {}) {
- return buildVirtualizedItems(dayGroups, leading, exhausted).map(
- virtualizedItemKey,
- );
+function keysOf(
+ dayGroups,
+ { leading = undefined, trailing = undefined, exhausted = false } = {},
+) {
+ return buildVirtualizedItems(
+ dayGroups,
+ leading,
+ exhausted,
+ true,
+ trailing,
+ ).map(virtualizedItemKey);
}
/**
@@ -65,6 +72,14 @@ test("oldest day carries no divider while more history exists", () => {
assert.deepEqual(keys, ["a3", "a4", "a5", "bottom-spacer"]);
});
+test("live activity renders after messages and before the composer spacer", () => {
+ const keys = keysOf([group("day-A", DAY_A, ["a1"])], {
+ trailing: "activity",
+ });
+
+ assert.deepEqual(keys, ["a1", "trailing-content", "bottom-spacer"]);
+});
+
test("oldest day divider renders once history is exhausted", () => {
const keys = keysOf([group("day-A", DAY_A, ["a3", "a4", "a5"])], {
exhausted: true,
diff --git a/desktop/src/features/messages/lib/virtualizedTimelineItems.ts b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts
index 1ab68af181..b67744c3ad 100644
--- a/desktop/src/features/messages/lib/virtualizedTimelineItems.ts
+++ b/desktop/src/features/messages/lib/virtualizedTimelineItems.ts
@@ -20,6 +20,10 @@ export type VirtualizedTimelineItem =
kind: "leading-content";
content: React.ReactNode;
}
+ | {
+ kind: "trailing-content";
+ content: React.ReactNode;
+ }
| { kind: "bottom-spacer" }
| { kind: "day-divider"; key: string; headingTimestamp: number }
| {
@@ -32,6 +36,7 @@ export function estimateVirtualizedTimelineItemHeight(
): number {
if (item.kind === "bottom-spacer") return 96;
if (item.kind === "leading-content") return 60;
+ if (item.kind === "trailing-content") return 240;
if (item.kind === "day-divider") return 56;
return estimateTimelineItemHeight(item.item);
}
@@ -39,6 +44,7 @@ export function estimateVirtualizedTimelineItemHeight(
export function virtualizedItemKey(item: VirtualizedTimelineItem): string {
if (item.kind === "bottom-spacer") return "bottom-spacer";
if (item.kind === "leading-content") return "leading-content";
+ if (item.kind === "trailing-content") return "trailing-content";
if (item.kind === "day-divider") return item.key;
return getTimelineItemKey(item.item);
}
@@ -65,6 +71,7 @@ export function buildVirtualizedItems(
leadingContent: React.ReactNode | undefined,
historyExhausted: boolean,
showDayDividers = true,
+ trailingContent?: React.ReactNode,
): VirtualizedTimelineItem[] {
const timelineItems = dayGroups.flatMap((group, groupIndex) => {
const boundaryProven = groupIndex > 0 || historyExhausted;
@@ -99,6 +106,14 @@ export function buildVirtualizedItems(
]
: []),
...timelineItems,
+ ...(trailingContent
+ ? [
+ {
+ kind: "trailing-content" as const,
+ content: trailingContent,
+ },
+ ]
+ : []),
{ kind: "bottom-spacer" as const },
];
}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 6d9c49b756..dbe9b9c201 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -80,6 +80,8 @@ type MessageTimelineProps = {
pinnedIntro?: React.ReactNode;
isFetchingOlder?: boolean;
messageFooters?: Record;
+ messageLeadingContent?: Record;
+ trailingContent?: React.ReactNode;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map;
profiles?: UserProfileLookup;
@@ -183,6 +185,8 @@ const MessageTimelineBase = React.forwardRef<
isFollowingThreadById,
isMessageUnreadById,
messageFooters,
+ messageLeadingContent,
+ trailingContent,
personaLookup,
profiles,
ownerProfiles,
@@ -649,8 +653,10 @@ const MessageTimelineBase = React.forwardRef<
entranceMessageId={entranceMessageId}
onEntranceMessageComplete={onEntranceMessageComplete}
messageFooters={messageFooters}
+ messageLeadingContent={messageLeadingContent}
mainEntries={renderedMessages === messages ? mainEntries : undefined}
leadingContent={virtualizedLeadingContent}
+ trailingContent={trailingContent}
historyExhausted={renderedHistoryExhausted}
hideDayDividers={hideDayDividers}
alwaysShowMessageIdentity={alwaysShowMessageIdentity}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index bf2da03f40..51562fc226 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -1,6 +1,6 @@
import * as React from "react";
import { VList } from "virtua";
-import type { VListHandle } from "virtua";
+import type { CustomItemComponentProps, VListHandle } from "virtua";
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
import {
@@ -60,6 +60,8 @@ type TimelineMessageListProps = {
entranceMessageId?: string | null;
onEntranceMessageComplete?: (messageId: string) => void;
messageFooters?: Record;
+ /** Owner-only content rendered immediately before a specific message row. */
+ messageLeadingContent?: Record;
/** Hoisted main-timeline entries (computed once in ChannelPane). Falls back
* to deriving them here when omitted (e.g. the deferred-render pass). */
mainEntries?: MainTimelineEntry[];
@@ -102,6 +104,8 @@ type TimelineMessageListProps = {
threadUnreadCounts?: ReadonlyMap;
/** Content rendered as the first virtual row before channel history. */
leadingContent?: React.ReactNode;
+ /** Live content rendered after channel history and before the composer gap. */
+ trailingContent?: React.ReactNode;
/** Hide date boundaries for a huddle's live transcript. */
hideDayDividers?: boolean;
/** Show speaker identity on every row instead of grouping consecutive messages. */
@@ -137,6 +141,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
entranceMessageId = null,
onEntranceMessageComplete,
messageFooters,
+ messageLeadingContent,
mainEntries,
threadSummaries,
messages,
@@ -157,6 +162,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
threadUnreadCounts,
unfollowThreadById,
leadingContent,
+ trailingContent,
historyExhausted = false,
hideDayDividers = false,
alwaysShowMessageIdentity = false,
@@ -240,48 +246,56 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
ownerProfiles={ownerProfiles}
/>
);
- case "message":
+ case "message": {
+ const leadingMessageContent =
+ messageLeadingContent?.[item.entry.message.id] ?? null;
return (
-
+ <>
+ {leadingMessageContent}
+
+ >
);
+ }
}
},
[
@@ -298,6 +312,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
entranceMessageId,
onEntranceMessageComplete,
messageFooters,
+ messageLeadingContent,
onDelete,
onEdit,
onMarkRead,
@@ -323,6 +338,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
historyExhausted={historyExhausted}
hideDayDividers={hideDayDividers}
leadingContent={leadingContent}
+ trailingContent={trailingContent}
onAtBottomStateChange={onAtBottomStateChange}
onStartReached={onStartReached}
onVirtualizerApiChange={onVirtualizerApiChange}
@@ -361,6 +377,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
))}
))}
+ {trailingContent}
);
});
@@ -379,6 +396,7 @@ type VirtualizedTimelineRowsProps = {
historyExhausted: boolean;
hideDayDividers: boolean;
leadingContent?: React.ReactNode;
+ trailingContent?: React.ReactNode;
onAtBottomStateChange?: (atBottom: boolean) => void;
onStartReached?: () => boolean;
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
@@ -387,26 +405,19 @@ type VirtualizedTimelineRowsProps = {
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
};
-type VirtualizedTimelineItemShellProps = {
- children: React.ReactNode;
- index: number;
- ref?: React.LegacyRef;
- style: React.CSSProperties;
-};
-
const PreserveVirtualizedItemVisibilityContext = React.createContext(false);
function VirtualizedTimelineItemShell({
children,
ref,
style,
-}: VirtualizedTimelineItemShellProps) {
+}: CustomItemComponentProps) {
const preserveVisibility = React.useContext(
PreserveVirtualizedItemVisibilityContext,
);
return (
}
style={preserveVisibility ? style : { ...style, visibility: undefined }}
>
{children}
@@ -419,6 +430,7 @@ function VirtualizedTimelineRows({
historyExhausted,
hideDayDividers,
leadingContent,
+ trailingContent,
onAtBottomStateChange,
onStartReached,
onVirtualizerApiChange,
@@ -459,8 +471,15 @@ function VirtualizedTimelineRows({
leadingContent,
historyExhausted,
!hideDayDividers,
+ trailingContent,
),
- [dayGroups, hideDayDividers, historyExhausted, leadingContent],
+ [
+ dayGroups,
+ hideDayDividers,
+ historyExhausted,
+ leadingContent,
+ trailingContent,
+ ],
);
const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]);
const dayDividerItems = React.useMemo(
@@ -761,6 +780,9 @@ function VirtualizedTimelineRows({
if (item.kind === "leading-content") {
return
{item.content}
;
}
+ if (item.kind === "trailing-content") {
+ return
{item.content}
;
+ }
if (item.kind === "day-divider") {
const dayLabel = formatDayHeading(item.headingTimestamp);
return (