diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx index 424837a068..faa919fe36 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -51,7 +51,7 @@ export function CompactMessageSummary({ const variant = useAgentSessionTranscriptVariant(); const { goChannel } = useAppNavigation(); const { openProfilePanel } = useProfilePanel(); - const isCompactPreview = variant === "compactPreview"; + const isCompactPreview = variant !== "default"; const shouldClampBubble = !isCompactPreview; const [bubbleRef, hasBubbleOverflow] = useTranscriptBubbleOverflow(shouldClampBubble); diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx index 2feeb8f971..eeefa803fb 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx @@ -38,7 +38,7 @@ export function CompactToolSummaryRow({ }) { const [thumbnailFailed, setThumbnailFailed] = React.useState(false); const variant = useAgentSessionTranscriptVariant(); - const isCompactPreview = variant === "compactPreview"; + const isCompactPreview = variant !== "default"; const mutedTone = compactSummaryTone(); const resolvedThumbnail = React.useMemo(() => { if (!thumbnailSrc || thumbnailFailed) return null; diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx index 0ac9a9195f..47844661fd 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx @@ -30,7 +30,7 @@ export function TodoToolSummary({ }) { const todos = buildTodoDisplayItems(item.args, item.result, fallbackPreview); const variant = useAgentSessionTranscriptVariant(); - const isCompactPreview = variant === "compactPreview"; + const isCompactPreview = variant !== "default"; const actionLabel = { verb: "Updated", object: fallbackPreview ?? "todos", @@ -84,7 +84,7 @@ export function isTodoSummary(summary: CompactToolSummary) { function TodoCheckboxRow({ todo }: { todo: TodoDisplayItem }) { const variant = useAgentSessionTranscriptVariant(); - const isCompactPreview = variant === "compactPreview"; + const isCompactPreview = variant !== "default"; return (
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 ? ( +
  • + ); +} + +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 ? ( +
    +
    +
    + + {presentation.label} + +
    + + {rendersExistingActivity ? ( + + ) : ( +
    +

    + {step.label} +

    + {step.detail ? ( +

    + {step.detail} +

    + ) : null} +
    + )} + + + + {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 ? ( + + ); +} + +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 (