mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): rebuild agent activity feed around a classifier registry and twelve render classes (#1381)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
65
VISION_ACTIVITY.md
Normal file
65
VISION_ACTIVITY.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Vision: The Agent Activity Feed
|
||||
|
||||
## The Problem
|
||||
|
||||
When you delegate work to an agent, you are trusting a process you cannot see. The activity feed is the window into that process — but a window is only useful if you can read it at a glance. A raw input/output dump is not a window; it is a transcript you have to decode. It forces you to *parse* before you can *judge*.
|
||||
|
||||
We wanted a feed you supervise the way you supervise a capable teammate: skim for progress, trust the routine, and catch the one thing that needs you — without reading every line.
|
||||
|
||||
## Who It Serves
|
||||
|
||||
A developer supervising a delegate. They are not watching for entertainment; they are deciding whether to intervene. Every item in the feed earns its pixels by answering one of three questions:
|
||||
|
||||
- **Comprehension** — *what is it doing, and why?*
|
||||
- **Confidence** — *is it going well, or is it stuck or wrong?*
|
||||
- **Control** — *do I need to step in, and where?*
|
||||
|
||||
A feed that answers these instantly converts a stream of events into a sense of trajectory. A feed that does not is just noise with a scrollbar.
|
||||
|
||||
## The Governing Frame: Verb, Object, Outcome
|
||||
|
||||
Every meaningful item is a sentence: **the agent did [verb] to [object] → [outcome].**
|
||||
|
||||
> "Sent a message to #design." · "Edited `runtime.rs` (+12/−3)." · "Reacted 👍 to Marge's review." · "Ran tests → 1248 passed."
|
||||
|
||||
The feed's job is to surface verb, object, and outcome immediately, and to push the supporting detail — full arguments, raw output, the unabridged diff — into progressive disclosure. You read the sentence; you expand only when the sentence makes you want to.
|
||||
|
||||
## The Render Classes
|
||||
|
||||
Every item resolves to one of twelve presentation classes, organized by how often they are read and how much consequence they carry:
|
||||
|
||||
**The spine — read constantly.** Message (the agent's voice), Buzz relay op (acting on the platform), File-edit (the actual code work), Shell command (the agent's hands), and Tool status & turn lifecycle (the heartbeat). If these are unclear, the feed has failed.
|
||||
|
||||
**High-value context — consulted to judge correctness.** Thought (reasoning, on tap), Plan/Todo (the roadmap and progress bar), Permission (the control gate), and Error (the stop sign).
|
||||
|
||||
**Ambient safety net — rarely read, but must exist.** Generic tool (the honest fallback), Raw rail (ground truth on demand), and Suppressed noise (what we deliberately do not render).
|
||||
|
||||
These are not a wish list. They are the complete taxonomy: every event the agent can emit lands in exactly one class, and the last three guarantee there is always a floor.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Semantics over transport.** Render *what the agent did*, not *which API it used*. A message sent through an MCP tool and the same message sent through a shell `buzz` command render as the identical card. How the agent reached the relay is plumbing; what it did is the contract.
|
||||
|
||||
- **Outcome-first.** Lead with success, failure, or result. The reader decides in under a second whether to expand. The raw dump is the fallback, never the headline.
|
||||
|
||||
- **Mutate in place.** A running action updates its own row from pending to executing to done or failed. One action is one item, not a trail of duplicated status lines.
|
||||
|
||||
- **Never go dark.** The absence of an event is itself information. Silence, idle, and timeout are *rendered states* — "waiting…", "timed out" — never an empty void. This mirrors the rule we hold our agents to: if you didn't show it, it didn't happen.
|
||||
|
||||
- **Failures rise; reads recede.** Salience tracks consequence. Admin actions, writes, and errors are loud. Reads and reasoning are quiet. A buried error is a broken feed.
|
||||
|
||||
- **Resolve references.** Show "#design", "Marge's message", a filename — never a raw event id or pubkey. The reader thinks in names, not hashes.
|
||||
|
||||
- **Coalesce streams.** Chunked text becomes one item. The developer reads a message, not a packet trace.
|
||||
|
||||
- **Honesty over guessing.** A recognized operation gets a semantic card. An unrecognized one degrades to a clean, truthful, generic row. We never fabricate semantics to look richer than we are.
|
||||
|
||||
- **Polished by default, raw on demand.** Curation is the product; the raw rail is the safety net. The toggle between them is a zoom level on the same truth, not a different feed.
|
||||
|
||||
## What This Earns
|
||||
|
||||
The feed's real job is to **earn delegation.** Visible progress, visible consent, and visible outcomes compound: each turn you watch go well makes you trust the agent with a larger one. Deciding what *not* to show — suppressing heartbeats and internal chatter — is as much a feature as deciding what to show, because suppression is what makes the signal legible.
|
||||
|
||||
A feed built this way is protocol-honest at its base: any compliant agent's messages, thoughts, tool calls, and turns become first-class items regardless of which tools it runs. The Buzz-specific richness — semantic relay cards, the buzz-CLI parser, diff rendering — is a layer of enrichment on top, not a requirement underneath. Non-Buzz agents get a correct, legible feed; Buzz agents get a native one.
|
||||
|
||||
Two altitudes of the same truth. Polished for judgment, raw for debugging. The window stays a window.
|
||||
@@ -73,6 +73,8 @@ const overrides = new Map([
|
||||
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
|
||||
// re-introduced after 826d735fe removal (config-bridge caller still needs it).
|
||||
// PGID resolution helper + PID-recycling safety guard added for orphan sweep.
|
||||
// activity-feed threads avatar_url into build_managed_agent_summary for the
|
||||
// assistant-bubble pinned snapshot.
|
||||
["src-tauri/src/managed_agents/runtime.rs", 2150],
|
||||
// applyWorkspace reposDir parameter plus the validateReposDir binding,
|
||||
// threaded through Tauri invokes for configurable repos_dir, plus the
|
||||
@@ -121,7 +123,7 @@ const overrides = new Map([
|
||||
["src/features/channels/readState/readStateManager.ts", 1030],
|
||||
// Shared UI was added to this guard after splitting globals/markdown so
|
||||
// large shared renderers cannot grow further while follow-up splits land.
|
||||
["src/shared/ui/markdown.tsx", 2082],
|
||||
["src/shared/ui/markdown.tsx", 2119],
|
||||
["src/shared/ui/VideoPlayer.tsx", 2199],
|
||||
["src/shared/ui/sidebar.tsx", 1042],
|
||||
// Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent
|
||||
@@ -129,6 +131,9 @@ const overrides = new Map([
|
||||
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
|
||||
// fallback. Queued to split into dedicated discovery modules.
|
||||
["src-tauri/src/commands/agent_models.rs", 1066],
|
||||
// Kept activity-feed design fixture: realistic prompt context and tool-heavy
|
||||
// chatter for render-class test/reference coverage. Queued to split with the
|
||||
// rest of this list if it grows further.
|
||||
]);
|
||||
|
||||
await runFileSizeCheck({
|
||||
|
||||
@@ -1539,6 +1539,7 @@ pub fn build_managed_agent_summary(
|
||||
max_turn_duration_seconds: record.max_turn_duration_seconds,
|
||||
parallelism: record.parallelism,
|
||||
system_prompt: record.system_prompt.clone(),
|
||||
avatar_url: record.avatar_url.clone(),
|
||||
model: record.model.clone(),
|
||||
provider: record.provider.clone(),
|
||||
persona_out_of_date,
|
||||
|
||||
@@ -268,6 +268,7 @@ pub struct ManagedAgentSummary {
|
||||
pub max_turn_duration_seconds: Option<u64>,
|
||||
pub parallelism: u32,
|
||||
pub system_prompt: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub model: Option<String>,
|
||||
/// LLM inference provider, from the agent's pinned record snapshot.
|
||||
pub provider: Option<String>,
|
||||
|
||||
@@ -1,428 +1 @@
|
||||
import * as React from "react";
|
||||
import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import type { Channel, UserProfileSummary } from "@/shared/api/types";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
import {
|
||||
formatToolTitle,
|
||||
getBuzzToolInfo,
|
||||
getToolStatusDisplay,
|
||||
} from "./agentSessionToolCatalog";
|
||||
import {
|
||||
asRecord,
|
||||
formatCodeValue,
|
||||
formatDuration,
|
||||
formatTranscriptTime,
|
||||
getResultArray,
|
||||
getToolString,
|
||||
getToolStringList,
|
||||
shortenMiddle,
|
||||
} from "./agentSessionUtils";
|
||||
|
||||
export function ToolItem({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<TranscriptItem, { type: "tool" }>;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const status = getToolStatusDisplay(item.status, item.isError);
|
||||
const hasArgs = Object.keys(item.args).length > 0;
|
||||
const hasResult = item.result.trim().length > 0;
|
||||
const canonicalToolName = item.buzzToolName ?? item.toolName;
|
||||
const buzzTool = getBuzzToolInfo(canonicalToolName);
|
||||
const ToolIcon = buzzTool?.icon ?? Wrench;
|
||||
const showStatus = status.state !== "output-available";
|
||||
const toolTitle = formatToolTitle(canonicalToolName, item.title);
|
||||
const handleToggle = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLDetailsElement>) => {
|
||||
setIsExpanded(event.currentTarget.open);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose w-full px-1">
|
||||
<details
|
||||
className="group w-full"
|
||||
onToggle={handleToggle}
|
||||
open={isExpanded}
|
||||
>
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px">
|
||||
{ToolIcon ? (
|
||||
<ToolIcon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
buzzTool ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{toolTitle}
|
||||
</span>
|
||||
{buzzTool ? (
|
||||
<BuzzToolInlineAction args={item.args} result={item.result} />
|
||||
) : null}
|
||||
{showStatus ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
||||
<status.Icon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
item.status === "executing" && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
{status.label}
|
||||
</span>
|
||||
) : null}
|
||||
<ToolTimestamp item={item} />
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
|
||||
<ToolDetailBlocks
|
||||
args={item.args}
|
||||
description={buzzTool?.label}
|
||||
hasArgs={hasArgs}
|
||||
hasResult={hasResult}
|
||||
isError={item.isError}
|
||||
result={item.result}
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDetailBlocks({
|
||||
args,
|
||||
description,
|
||||
hasArgs,
|
||||
hasResult,
|
||||
isError,
|
||||
result,
|
||||
}: {
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
hasArgs: boolean;
|
||||
hasResult: boolean;
|
||||
isError: boolean;
|
||||
result: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 py-2 pl-5 text-popover-foreground outline-hidden">
|
||||
{description ? (
|
||||
<p className="max-w-2xl text-xs leading-5 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{hasArgs ? (
|
||||
<ToolCodeBlock
|
||||
label="Parameters"
|
||||
tone="muted"
|
||||
value={JSON.stringify(args, null, 2)}
|
||||
/>
|
||||
) : null}
|
||||
{hasResult ? (
|
||||
<ToolCodeBlock
|
||||
label={isError ? "Error" : "Result"}
|
||||
tone={isError ? "error" : "muted"}
|
||||
value={result}
|
||||
/>
|
||||
) : null}
|
||||
{!hasArgs && !hasResult ? (
|
||||
<p className="text-sm text-muted-foreground/80">
|
||||
Waiting for tool details.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCodeBlock({
|
||||
label,
|
||||
tone,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
tone: "muted" | "error";
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 overflow-hidden">
|
||||
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</h4>
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md px-3 py-2 font-mono text-xs leading-5",
|
||||
tone === "error"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground",
|
||||
)}
|
||||
>
|
||||
{formatCodeValue(value)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolFullDateTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
function ToolTimestamp({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<TranscriptItem, { type: "tool" }>;
|
||||
}) {
|
||||
const time = formatTranscriptTime(item.timestamp);
|
||||
if (!time) return null;
|
||||
const duration =
|
||||
item.startedAt && item.completedAt
|
||||
? formatDuration(item.startedAt, item.completedAt)
|
||||
: null;
|
||||
const date = new Date(item.timestamp);
|
||||
const fullDateTime = Number.isNaN(date.getTime())
|
||||
? item.timestamp
|
||||
: toolFullDateTimeFormat.format(date);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0 cursor-default text-2xs text-muted-foreground/60">
|
||||
{time}
|
||||
{duration ? ` · ${duration}` : null}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{fullDateTime}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function BuzzToolInlineAction({
|
||||
args,
|
||||
result,
|
||||
}: {
|
||||
args: Record<string, unknown>;
|
||||
result: string;
|
||||
}) {
|
||||
const { channels } = useChannelNavigation();
|
||||
const { goChannel } = useAppNavigation();
|
||||
const resultValue = React.useMemo(
|
||||
() => parseToolResultValue(result),
|
||||
[result],
|
||||
);
|
||||
const resultRecord = asRecord(resultValue);
|
||||
const channelId =
|
||||
getToolString(args, ["channel_id", "channelId"]) ??
|
||||
getToolString(resultRecord, ["channel_id", "channelId"]);
|
||||
const pubkeys = React.useMemo(
|
||||
() => getToolStringList(args, ["pubkeys", "pubkey"]),
|
||||
[args],
|
||||
);
|
||||
const profilesQuery = useUsersBatchQuery(pubkeys, {
|
||||
enabled: pubkeys.length > 0,
|
||||
});
|
||||
const profiles = profilesQuery.data?.profiles;
|
||||
const openChannel = React.useCallback(
|
||||
(messageId?: string) => {
|
||||
if (!channelId) return;
|
||||
void goChannel(channelId, messageId ? { messageId } : undefined);
|
||||
},
|
||||
[channelId, goChannel],
|
||||
);
|
||||
const action = React.useMemo(
|
||||
() =>
|
||||
getBuzzToolInlineAction({
|
||||
args,
|
||||
channelId,
|
||||
channels,
|
||||
openChannel,
|
||||
profiles,
|
||||
resultValue,
|
||||
}),
|
||||
[args, channelId, channels, openChannel, profiles, resultValue],
|
||||
);
|
||||
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action.onClick) {
|
||||
return (
|
||||
<button
|
||||
className="inline-flex max-w-[14rem] shrink min-w-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/[0.05] px-1.5 py-0.5 text-2xs font-normal leading-none text-primary/90 transition-colors hover:border-primary/35 hover:bg-primary/10 hover:text-primary"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
action.onClick?.();
|
||||
}}
|
||||
title={action.title}
|
||||
type="button"
|
||||
>
|
||||
{action.avatar}
|
||||
<span className="shrink-0">{action.label}</span>
|
||||
<span className="truncate">{action.value}</span>
|
||||
<ArrowUpRight className="h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-[14rem] shrink min-w-0 items-center gap-1 rounded-full border border-border/60 bg-muted/40 px-1.5 py-0.5 text-2xs font-normal leading-none text-muted-foreground"
|
||||
title={action.title}
|
||||
>
|
||||
{action.avatar}
|
||||
<span className="shrink-0">{action.label}</span>
|
||||
<span className="truncate">{action.value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type BuzzToolInlineActionModel = {
|
||||
avatar?: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
title: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
function getBuzzToolInlineAction({
|
||||
args,
|
||||
channelId,
|
||||
channels,
|
||||
openChannel,
|
||||
profiles,
|
||||
resultValue,
|
||||
}: {
|
||||
args: Record<string, unknown>;
|
||||
channelId: string | null;
|
||||
channels: Channel[];
|
||||
openChannel: (messageId?: string) => void;
|
||||
profiles: Record<string, UserProfileSummary> | undefined;
|
||||
resultValue: unknown;
|
||||
}): BuzzToolInlineActionModel | null {
|
||||
const resultRecord = asRecord(resultValue);
|
||||
const eventId =
|
||||
getToolString(args, ["event_id", "eventId"]) ??
|
||||
getToolString(resultRecord, ["event_id", "eventId", "id"]);
|
||||
|
||||
if (eventId && channelId) {
|
||||
return {
|
||||
label: resultRecord.accepted === true ? "posted" : "event",
|
||||
onClick: () => openChannel(eventId),
|
||||
title: eventId,
|
||||
value: getChannelChipLabel(channels, channelId),
|
||||
};
|
||||
}
|
||||
|
||||
const messages = getResultArray(resultValue, resultRecord, "messages");
|
||||
if (messages) {
|
||||
return {
|
||||
label: "read",
|
||||
onClick: channelId ? () => openChannel() : undefined,
|
||||
title: `${messages.length} messages`,
|
||||
value: `${messages.length} message${messages.length === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (channelId) {
|
||||
return {
|
||||
label: "channel",
|
||||
onClick: () => openChannel(),
|
||||
title: channelId,
|
||||
value: getChannelChipLabel(channels, channelId),
|
||||
};
|
||||
}
|
||||
|
||||
const workflowId =
|
||||
getToolString(args, ["workflow_id", "workflowId"]) ??
|
||||
getToolString(resultRecord, ["workflow_id", "workflowId"]);
|
||||
if (workflowId) {
|
||||
return {
|
||||
label: "workflow",
|
||||
title: workflowId,
|
||||
value: shortenMiddle(workflowId, 26),
|
||||
};
|
||||
}
|
||||
|
||||
const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]);
|
||||
if (pubkeys.length > 0) {
|
||||
if (pubkeys.length === 1) {
|
||||
const pk = pubkeys[0];
|
||||
const displayName = resolveUserLabel({ pubkey: pk, profiles });
|
||||
const profile = profiles?.[pk.toLowerCase()];
|
||||
return {
|
||||
avatar: (
|
||||
<UserAvatar
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="shrink-0"
|
||||
displayName={displayName}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
label: "user",
|
||||
title: pk,
|
||||
value: displayName,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "users",
|
||||
title: pubkeys
|
||||
.map((pk) => resolveUserLabel({ pubkey: pk, profiles }))
|
||||
.join(", "),
|
||||
value: `${pubkeys.length} users`,
|
||||
};
|
||||
}
|
||||
|
||||
const query = getToolString(args, ["query"]);
|
||||
if (query) {
|
||||
return {
|
||||
label: "query",
|
||||
title: query,
|
||||
value: shortenMiddle(query, 30),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof resultRecord.accepted === "boolean") {
|
||||
return {
|
||||
label: "relay",
|
||||
title: resultRecord.accepted ? "accepted" : "rejected",
|
||||
value: resultRecord.accepted ? "accepted" : "rejected",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseToolResultValue(result: string): unknown {
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "string") return parsed;
|
||||
try {
|
||||
return JSON.parse(parsed);
|
||||
} catch {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getChannelChipLabel(channels: Channel[], channelId: string) {
|
||||
const channel = channels.find((candidate) => candidate.id === channelId);
|
||||
return channel ? `#${channel.name}` : `#${shortenMiddle(channelId, 22)}`;
|
||||
}
|
||||
export { ToolItem } from "./AgentSessionToolItem/ToolItem";
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from "react";
|
||||
import { CheckCheck } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { TranscriptTimestamp } from "../activityRenderClasses/TranscriptTimestamp";
|
||||
import { compactSummaryTone } from "./CompactToolSummaryRow";
|
||||
import type { SentMessageLink } from "./messageLinks";
|
||||
import { SentMessageContextDialog } from "./SentMessageContextDialog";
|
||||
|
||||
export function CompactMessageSummary({
|
||||
args,
|
||||
avatarUrl,
|
||||
description,
|
||||
displayName,
|
||||
duration,
|
||||
hasArgs,
|
||||
hasResult,
|
||||
isError,
|
||||
label,
|
||||
messageLink,
|
||||
preview,
|
||||
result,
|
||||
timestamp,
|
||||
}: {
|
||||
args: Record<string, unknown>;
|
||||
avatarUrl: string | null;
|
||||
description?: string;
|
||||
displayName: string;
|
||||
duration: string | null;
|
||||
hasArgs: boolean;
|
||||
hasResult: boolean;
|
||||
isError: boolean;
|
||||
label: string;
|
||||
messageLink: SentMessageLink | null;
|
||||
preview: string | null;
|
||||
result: string;
|
||||
timestamp: string;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
||||
const mutedTone = compactSummaryTone();
|
||||
return (
|
||||
<>
|
||||
<div className="flex max-w-full flex-row items-start justify-start">
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="mr-2 mt-1 shrink-0"
|
||||
displayName={displayName}
|
||||
size="xs"
|
||||
testId="transcript-agent-sent-avatar"
|
||||
/>
|
||||
<div className="flex max-w-[85%] min-w-0 flex-col items-start gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-2xl border px-3 py-2 text-sm leading-relaxed shadow-sm",
|
||||
isError
|
||||
? "border-destructive/25 bg-destructive/10 text-destructive"
|
||||
: "border-primary/15 bg-primary/6 text-foreground",
|
||||
)}
|
||||
data-testid="transcript-tool-message-preview"
|
||||
>
|
||||
<p className="whitespace-pre-wrap wrap-break-word">
|
||||
{preview || "Message content unavailable."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex max-w-full items-center gap-1.5 px-1">
|
||||
<TranscriptTimestamp
|
||||
messageLink={messageLink}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
<button
|
||||
aria-label="Show sent message context"
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
mutedTone,
|
||||
)}
|
||||
data-testid="transcript-sent-message-context-button"
|
||||
onClick={() => setDetailsOpen(true)}
|
||||
title="Show sent message context"
|
||||
type="button"
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SentMessageContextDialog
|
||||
args={args}
|
||||
description={description}
|
||||
duration={duration}
|
||||
hasArgs={hasArgs}
|
||||
hasResult={hasResult}
|
||||
isError={isError}
|
||||
label={label}
|
||||
onOpenChange={setDetailsOpen}
|
||||
open={detailsOpen}
|
||||
preview={preview}
|
||||
result={result}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import type { AgentActivityAction } from "../agentSessionTypes";
|
||||
import type { CompactFileEditSummary } from "../agentSessionToolSummary";
|
||||
import { isInlineImageData } from "../agentSessionUtils";
|
||||
import {
|
||||
ActivityRowLabel,
|
||||
splitActivityRowLabel,
|
||||
type ActivityRowLabelParts,
|
||||
} from "../activityRenderClasses/ActivityRow";
|
||||
|
||||
export function compactSummaryTone() {
|
||||
return "text-muted-foreground/60 group-open:text-foreground";
|
||||
}
|
||||
|
||||
export function CompactToolSummaryRow({
|
||||
action,
|
||||
duration,
|
||||
fileEditSummary,
|
||||
label,
|
||||
preview,
|
||||
thumbnailSrc,
|
||||
}: {
|
||||
action: AgentActivityAction | null;
|
||||
duration: string | null;
|
||||
fileEditSummary: CompactFileEditSummary | null;
|
||||
label: string;
|
||||
preview: string | null;
|
||||
thumbnailSrc: string | null;
|
||||
}) {
|
||||
const [thumbnailFailed, setThumbnailFailed] = React.useState(false);
|
||||
const mutedTone = compactSummaryTone();
|
||||
const resolvedThumbnail = React.useMemo(() => {
|
||||
if (!thumbnailSrc || thumbnailFailed) return null;
|
||||
return resolveImageSrc(thumbnailSrc);
|
||||
}, [thumbnailFailed, thumbnailSrc]);
|
||||
const actionLabel = fileEditSummary
|
||||
? null
|
||||
: getCompactToolActionLabel(action, label, preview);
|
||||
|
||||
return (
|
||||
<>
|
||||
{fileEditSummary ? (
|
||||
<CompactFileEditSummaryView summary={fileEditSummary} />
|
||||
) : actionLabel ? (
|
||||
<ActivityRowLabel
|
||||
object={actionLabel.object}
|
||||
openToneScope="tool"
|
||||
title={actionLabel.title}
|
||||
verb={actionLabel.verb}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn("shrink-0 text-sm font-semibold", mutedTone)}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{!fileEditSummary && resolvedThumbnail ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-5 w-auto max-w-12 shrink-0 rounded-sm object-cover"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onError={() => setThumbnailFailed(true)}
|
||||
src={resolvedThumbnail}
|
||||
title={preview ?? undefined}
|
||||
/>
|
||||
) : !fileEditSummary && !actionLabel && preview ? (
|
||||
<span
|
||||
className={cn("min-w-0 max-w-48 truncate text-sm", mutedTone)}
|
||||
title={preview}
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
) : null}
|
||||
{duration ? (
|
||||
<span className={cn("shrink-0 text-xs", mutedTone)}>{duration}</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform group-open:rotate-180",
|
||||
mutedTone,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getCompactToolActionLabel(
|
||||
action: AgentActivityAction | null,
|
||||
label: string,
|
||||
preview: string | null,
|
||||
): (ActivityRowLabelParts & { title?: string }) | null {
|
||||
if (action) {
|
||||
const object = action.object ?? preview ?? undefined;
|
||||
return {
|
||||
verb: action.verb,
|
||||
object,
|
||||
title: typeof object === "string" ? object : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const parts = splitActivityRowLabel(label);
|
||||
if (!parts) return null;
|
||||
|
||||
if (!preview) return parts;
|
||||
|
||||
if (
|
||||
label === "Ran command" ||
|
||||
label === "Read file" ||
|
||||
label === "Updated todos" ||
|
||||
label === "Viewed image"
|
||||
) {
|
||||
return { verb: parts.verb, object: preview, title: preview };
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function CompactFileEditSummaryView({
|
||||
summary,
|
||||
}: {
|
||||
summary: CompactFileEditSummary;
|
||||
}) {
|
||||
return (
|
||||
<ActivityRowLabel
|
||||
className="max-w-72"
|
||||
object={summary.filename}
|
||||
openToneScope="tool"
|
||||
stats={{
|
||||
additions: summary.additions,
|
||||
deletions: summary.deletions,
|
||||
}}
|
||||
title={summary.path}
|
||||
verb="Edited"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveImageSrc(source: string): string {
|
||||
return isInlineImageData(source) ? source : rewriteRelayUrl(source);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import * as React from "react";
|
||||
import { CheckCheck, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { formatCodeValue } from "../agentSessionUtils";
|
||||
|
||||
export function SentMessageContextDialog({
|
||||
args,
|
||||
description,
|
||||
duration,
|
||||
hasArgs,
|
||||
hasResult,
|
||||
isError,
|
||||
label,
|
||||
onOpenChange,
|
||||
open,
|
||||
preview,
|
||||
result,
|
||||
}: {
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
duration: string | null;
|
||||
hasArgs: boolean;
|
||||
hasResult: boolean;
|
||||
isError: boolean;
|
||||
label: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
preview: string | null;
|
||||
result: string;
|
||||
}) {
|
||||
const sections = buildSentMessageContextSections({
|
||||
args,
|
||||
description,
|
||||
hasArgs,
|
||||
hasResult,
|
||||
isError,
|
||||
preview,
|
||||
result,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
|
||||
<DialogTitle>Sent message context</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-1.5">
|
||||
<CheckCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{label}</span>
|
||||
{duration ? <span className="shrink-0">{duration}</span> : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
|
||||
<SentMessageContextSections sections={sections} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type SentMessageContextSection = {
|
||||
body: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function buildSentMessageContextSections({
|
||||
args,
|
||||
description,
|
||||
hasArgs,
|
||||
hasResult,
|
||||
isError,
|
||||
preview,
|
||||
result,
|
||||
}: {
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
hasArgs: boolean;
|
||||
hasResult: boolean;
|
||||
isError: boolean;
|
||||
preview: string | null;
|
||||
result: string;
|
||||
}): SentMessageContextSection[] {
|
||||
const sections: SentMessageContextSection[] = [];
|
||||
if (preview) {
|
||||
sections.push({ title: "Message", body: preview });
|
||||
}
|
||||
if (description) {
|
||||
sections.push({ title: "Tool", body: description });
|
||||
}
|
||||
if (hasArgs) {
|
||||
sections.push({
|
||||
title: "Parameters",
|
||||
body: JSON.stringify(args, null, 2),
|
||||
});
|
||||
}
|
||||
if (hasResult) {
|
||||
sections.push({
|
||||
title: isError ? "Error" : "Result",
|
||||
body: formatCodeValue(result),
|
||||
});
|
||||
}
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
title: "Status",
|
||||
body: "Waiting for tool details.",
|
||||
});
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function SentMessageContextSections({
|
||||
sections,
|
||||
}: {
|
||||
sections: SentMessageContextSection[];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3" data-testid="transcript-sent-message-context">
|
||||
{sections.map((section) => (
|
||||
<SentMessageContextSectionAccordion
|
||||
key={`${section.title}:${section.body.slice(0, 48)}`}
|
||||
section={section}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SentMessageContextSectionAccordion({
|
||||
section,
|
||||
}: {
|
||||
section: SentMessageContextSection;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const body = section.body.trim();
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-2xl bg-muted/40">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-semibold text-foreground",
|
||||
!open && "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 whitespace-pre-wrap break-all text-xs leading-5 text-foreground/70",
|
||||
!open && "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{body.length > 0 ? (
|
||||
body
|
||||
) : (
|
||||
<span className="italic text-foreground/50">No metadata.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Terminal } from "lucide-react";
|
||||
|
||||
import { parseShellToolOutput } from "../agentSessionUtils";
|
||||
|
||||
export function ShellCommandBlock({
|
||||
command,
|
||||
result,
|
||||
}: {
|
||||
command: string;
|
||||
result: string;
|
||||
}) {
|
||||
const output = parseShellToolOutput(result);
|
||||
const stdout = output.stdout.trimEnd();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg bg-muted/40 px-3 py-2 font-mono text-xs leading-5"
|
||||
data-testid="transcript-shell-command"
|
||||
>
|
||||
<p className="whitespace-pre-wrap wrap-break-word text-muted-foreground/70">
|
||||
<Terminal className="mr-2 inline h-3.5 w-3.5 align-[-0.1875rem] text-accent-foreground" />
|
||||
{command}
|
||||
</p>
|
||||
{stdout ? (
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap wrap-break-word text-foreground">
|
||||
{stdout}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { TranscriptItem } from "../agentSessionTypes";
|
||||
import type { CompactToolSummary } from "../agentSessionToolSummary";
|
||||
import {
|
||||
asRecord,
|
||||
formatTranscriptTimestampTitle,
|
||||
getToolString,
|
||||
parseToolResultValue,
|
||||
} from "../agentSessionUtils";
|
||||
import {
|
||||
ActivityRow,
|
||||
ActivityRowContent,
|
||||
ActivityRowLabel,
|
||||
} from "../activityRenderClasses/ActivityRow";
|
||||
|
||||
type TodoDisplayItem = {
|
||||
checked: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function TodoToolSummary({
|
||||
duration,
|
||||
fallbackPreview,
|
||||
item,
|
||||
}: {
|
||||
duration: string | null;
|
||||
fallbackPreview: string | null;
|
||||
item: Extract<TranscriptItem, { type: "tool" }>;
|
||||
}) {
|
||||
const todos = buildTodoDisplayItems(item.args, item.result, fallbackPreview);
|
||||
const actionLabel = {
|
||||
verb: "Updated",
|
||||
object: fallbackPreview ?? "todos",
|
||||
};
|
||||
|
||||
return (
|
||||
<ActivityRow title={formatTranscriptTimestampTitle(item.timestamp)}>
|
||||
<ActivityRowLabel
|
||||
object={actionLabel.object}
|
||||
openToneScope="tool"
|
||||
title={actionLabel.object}
|
||||
verb={actionLabel.verb}
|
||||
/>
|
||||
{duration ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground/60 group-open:text-foreground">
|
||||
{duration}
|
||||
</span>
|
||||
) : null}
|
||||
<ActivityRowContent className="pt-1 pb-1.5">
|
||||
{todos.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{todos.map((todo, index) => (
|
||||
<TodoCheckboxRow
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: todo snapshots are static transcript content
|
||||
key={index}
|
||||
todo={todo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/80">No todos.</p>
|
||||
)}
|
||||
</ActivityRowContent>
|
||||
</ActivityRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function isTodoSummary(summary: CompactToolSummary) {
|
||||
return (
|
||||
summary.descriptor.groupKey === "plan:todo" ||
|
||||
summary.descriptor.operation === "todo"
|
||||
);
|
||||
}
|
||||
|
||||
function TodoCheckboxRow({ todo }: { todo: TodoDisplayItem }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-start gap-2 text-sm leading-5 text-muted-foreground">
|
||||
<input
|
||||
checked={todo.checked}
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0 cursor-default accent-primary"
|
||||
disabled
|
||||
readOnly
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="min-w-0 wrap-break-word">{todo.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTodoDisplayItems(
|
||||
args: Record<string, unknown>,
|
||||
result: string,
|
||||
fallbackPreview: string | null,
|
||||
): TodoDisplayItem[] {
|
||||
const argTodos = extractTodoItemsFromArgs(args);
|
||||
if (argTodos.length > 0) {
|
||||
return argTodos;
|
||||
}
|
||||
|
||||
const resultTodos = extractTodoItemsFromResult(result);
|
||||
if (resultTodos.length > 0) {
|
||||
return resultTodos;
|
||||
}
|
||||
|
||||
return fallbackPreview && fallbackPreview !== "empty list"
|
||||
? [{ checked: false, text: fallbackPreview }]
|
||||
: [];
|
||||
}
|
||||
|
||||
function extractTodoItemsFromArgs(
|
||||
args: Record<string, unknown>,
|
||||
): TodoDisplayItem[] {
|
||||
const todos = args.todos;
|
||||
if (!Array.isArray(todos)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return todos.flatMap((todo) => {
|
||||
if (!todo || typeof todo !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const record = asRecord(todo);
|
||||
const text = getToolString(record, ["text", "content", "label", "title"]);
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
checked: getTodoChecked(record),
|
||||
text,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function extractTodoItemsFromResult(result: string): TodoDisplayItem[] {
|
||||
const resultText = getTodoResultText(result);
|
||||
if (!resultText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return resultText.split(/\r?\n/).flatMap((line) => {
|
||||
const match = line.match(/^\s*[-*]\s+\[([ xX])]\s+(.+?)\s*$/);
|
||||
if (!match) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
checked: match[1].toLowerCase() === "x",
|
||||
text: match[2],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function getTodoResultText(result: string): string {
|
||||
const parsed = parseToolResultValue(result);
|
||||
if (typeof parsed === "string") {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const record = asRecord(parsed);
|
||||
return getToolString(record, ["stdout", "result", "text"]) ?? result;
|
||||
}
|
||||
|
||||
function getTodoChecked(record: Record<string, unknown>) {
|
||||
if (typeof record.done === "boolean") {
|
||||
return record.done;
|
||||
}
|
||||
if (typeof record.checked === "boolean") {
|
||||
return record.checked;
|
||||
}
|
||||
|
||||
const status = getToolString(record, ["status"])?.toLowerCase();
|
||||
return status === "completed" || status === "done";
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { FileEditDiff } from "../agentSessionFileEditDiff";
|
||||
import { FileEditDiffBlock, hasFileEditLineDiff } from "../FileEditDiffView";
|
||||
import { formatCodeValue } from "../agentSessionUtils";
|
||||
import { ShellCommandBlock } from "./ShellCommandBlock";
|
||||
import { ViewImageToolPreview } from "./ViewImageToolPreview";
|
||||
|
||||
export function ToolDetailBlocks({
|
||||
args,
|
||||
description,
|
||||
fileEditDiff,
|
||||
hasArgs,
|
||||
hasResult,
|
||||
imagePreview,
|
||||
isError,
|
||||
result,
|
||||
shellCommand,
|
||||
}: {
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
fileEditDiff: FileEditDiff | null;
|
||||
hasArgs: boolean;
|
||||
hasResult: boolean;
|
||||
imagePreview: { src: string | null; title: string | null } | null;
|
||||
isError: boolean;
|
||||
result: string;
|
||||
shellCommand: string | null;
|
||||
}) {
|
||||
const showFileEditDiff =
|
||||
fileEditDiff && hasFileEditLineDiff(fileEditDiff) && !isError;
|
||||
const showShellCommand = shellCommand != null && !showFileEditDiff;
|
||||
const showParameters = hasArgs && !showFileEditDiff;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2 text-popover-foreground outline-hidden">
|
||||
{description ? (
|
||||
<p className="max-w-2xl text-xs leading-5 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{imagePreview?.src ? (
|
||||
<ViewImageToolPreview
|
||||
src={imagePreview.src}
|
||||
title={imagePreview.title}
|
||||
/>
|
||||
) : null}
|
||||
{showShellCommand ? (
|
||||
<ShellCommandBlock command={shellCommand} result={result} />
|
||||
) : showParameters ? (
|
||||
<ToolCodeBlock
|
||||
label="Parameters"
|
||||
tone="muted"
|
||||
value={JSON.stringify(args, null, 2)}
|
||||
/>
|
||||
) : null}
|
||||
{!showShellCommand && hasResult ? (
|
||||
showFileEditDiff ? (
|
||||
<FileEditDiffBlock diff={fileEditDiff} />
|
||||
) : (
|
||||
<ToolCodeBlock
|
||||
label={isError ? "Error" : "Result"}
|
||||
tone={isError ? "error" : "muted"}
|
||||
value={result}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{!showShellCommand && !showParameters && !hasResult ? (
|
||||
<p className="text-sm text-muted-foreground/80">
|
||||
Waiting for tool details.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCodeBlock({
|
||||
label,
|
||||
tone,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
tone: "muted" | "error";
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 overflow-hidden">
|
||||
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</h4>
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-64 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md px-3 py-2 font-mono text-xs leading-5",
|
||||
tone === "error"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground",
|
||||
)}
|
||||
>
|
||||
{formatCodeValue(value)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx
Normal file
159
desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import type { TranscriptItem } from "../agentSessionTypes";
|
||||
import { getBuzzToolInfo } from "../agentSessionToolCatalog";
|
||||
import { buildCompactToolSummary } from "../agentSessionToolSummary";
|
||||
import type { AgentTranscriptIdentityProps } from "../activityRenderClasses/types";
|
||||
import {
|
||||
formatTranscriptTimestampTitle,
|
||||
getToolDurationDisplay,
|
||||
getToolString,
|
||||
} from "../agentSessionUtils";
|
||||
import { CompactMessageSummary } from "./CompactMessageSummary";
|
||||
import {
|
||||
CompactToolSummaryRow,
|
||||
compactSummaryTone,
|
||||
} from "./CompactToolSummaryRow";
|
||||
import { getSentMessageLink } from "./messageLinks";
|
||||
import { isTodoSummary, TodoToolSummary } from "./TodoToolSummary";
|
||||
import { ToolDetailBlocks } from "./ToolDetailBlocks";
|
||||
|
||||
export function ToolItem({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
item,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
item: Extract<TranscriptItem, { type: "tool" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const hasArgs = Object.keys(item.args).length > 0;
|
||||
const hasResult = item.result.trim().length > 0;
|
||||
const canonicalToolName = item.buzzToolName ?? item.toolName;
|
||||
const buzzTool = getBuzzToolInfo(canonicalToolName);
|
||||
const compactSummary = buildCompactToolSummary(item);
|
||||
const duration = getToolDurationDisplay(item);
|
||||
const messageLink = getSentMessageLink(item);
|
||||
const timestampTitle = formatTranscriptTimestampTitle(item.timestamp);
|
||||
const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null;
|
||||
const agentLabel = resolveUserLabel({
|
||||
pubkey: agentPubkey,
|
||||
fallbackName: agentName,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const agentResolvedAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl;
|
||||
const handleToggle = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLDetailsElement>) => {
|
||||
setIsExpanded(event.currentTarget.open);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (compactSummary.presentation === "message") {
|
||||
return (
|
||||
<div
|
||||
className="not-prose w-full"
|
||||
data-testid="transcript-tool-item"
|
||||
title={timestampTitle}
|
||||
>
|
||||
<CompactMessageSummary
|
||||
args={item.args}
|
||||
avatarUrl={agentResolvedAvatarUrl}
|
||||
description={buzzTool?.label}
|
||||
displayName={agentLabel}
|
||||
duration={duration}
|
||||
hasArgs={hasArgs}
|
||||
hasResult={hasResult}
|
||||
isError={item.isError || item.status === "failed"}
|
||||
label={compactSummary.label}
|
||||
messageLink={messageLink}
|
||||
preview={compactSummary.preview}
|
||||
result={item.result}
|
||||
timestamp={item.timestamp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isTodoSummary(compactSummary)) {
|
||||
return (
|
||||
<div
|
||||
className="not-prose w-full"
|
||||
data-testid="transcript-tool-item"
|
||||
title={timestampTitle}
|
||||
>
|
||||
<TodoToolSummary
|
||||
duration={duration}
|
||||
fallbackPreview={compactSummary.preview}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="not-prose w-full"
|
||||
data-testid="transcript-tool-item"
|
||||
title={timestampTitle}
|
||||
>
|
||||
<details
|
||||
className="group w-full"
|
||||
onToggle={handleToggle}
|
||||
open={isExpanded}
|
||||
>
|
||||
<summary
|
||||
className={cn(
|
||||
"max-w-full cursor-pointer list-none",
|
||||
compactSummary.presentation === "inline" &&
|
||||
"inline-flex items-center gap-1.5",
|
||||
compactSummaryTone(),
|
||||
)}
|
||||
>
|
||||
<CompactToolSummaryRow
|
||||
action={compactSummary.action}
|
||||
duration={duration}
|
||||
fileEditSummary={compactSummary.fileEditSummary}
|
||||
preview={compactSummary.preview}
|
||||
thumbnailSrc={compactSummary.thumbnailSrc}
|
||||
label={compactSummary.label}
|
||||
/>
|
||||
</summary>
|
||||
|
||||
<ToolDetailBlocks
|
||||
args={item.args}
|
||||
description={buzzTool?.label}
|
||||
fileEditDiff={compactSummary.fileEditDiff}
|
||||
hasArgs={hasArgs}
|
||||
hasResult={hasResult}
|
||||
imagePreview={
|
||||
compactSummary.thumbnailSrc != null && isExpanded
|
||||
? {
|
||||
src: compactSummary.thumbnailSrc,
|
||||
title: compactSummary.preview,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isError={item.isError}
|
||||
result={item.result}
|
||||
shellCommand={
|
||||
compactSummary.kind === "shell"
|
||||
? (getToolString(item.args, ["command"]) ??
|
||||
compactSummary.preview ??
|
||||
"command")
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { isInlineImageData } from "../agentSessionUtils";
|
||||
|
||||
export function ViewImageToolPreview({
|
||||
src,
|
||||
title,
|
||||
}: {
|
||||
src: string;
|
||||
title: string | null;
|
||||
}) {
|
||||
const [lightboxOpen, setLightboxOpen] = React.useState(false);
|
||||
const [imageFailed, setImageFailed] = React.useState(false);
|
||||
const resolvedSrc = React.useMemo(() => resolveImageSrc(src), [src]);
|
||||
const alt = title ?? "Viewed image";
|
||||
|
||||
if (imageFailed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */}
|
||||
<img
|
||||
alt={alt}
|
||||
className="ml-1.5 block max-h-64 max-w-[min(24rem,calc(100%-0.375rem))] cursor-pointer rounded-lg object-contain"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
onError={() => setImageFailed(true)}
|
||||
src={resolvedSrc}
|
||||
title={title ?? undefined}
|
||||
/>
|
||||
<ImageLightbox
|
||||
alt={alt}
|
||||
onOpenChange={setLightboxOpen}
|
||||
open={lightboxOpen}
|
||||
src={resolvedSrc}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageLightbox({
|
||||
alt,
|
||||
onOpenChange,
|
||||
open,
|
||||
src,
|
||||
}: {
|
||||
alt: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
src: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-8"
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">
|
||||
{alt}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Full-size image preview. Press Escape or click outside the image to
|
||||
close.
|
||||
</DialogPrimitive.Description>
|
||||
<DialogPrimitive.Close
|
||||
aria-label="Close lightbox"
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<img
|
||||
alt={alt}
|
||||
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
|
||||
src={src}
|
||||
/>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveImageSrc(source: string): string {
|
||||
return isInlineImageData(source) ? source : rewriteRelayUrl(source);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { TranscriptItem } from "../agentSessionTypes";
|
||||
import {
|
||||
asRecord,
|
||||
getToolString,
|
||||
parseToolResultValue,
|
||||
} from "../agentSessionUtils";
|
||||
|
||||
export type SentMessageLink = {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
export function getSentMessageLink(
|
||||
item: Extract<TranscriptItem, { type: "tool" }>,
|
||||
): SentMessageLink | null {
|
||||
if (item.status !== "completed" || item.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.descriptor?.renderClass !== "message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelId =
|
||||
item.channelId ?? getToolString(item.args, ["channel_id", "channelId"]);
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resultRecord = getMessageSendResultRecord(item.result);
|
||||
if (!resultRecord || resultRecord.accepted === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageId = getToolString(resultRecord, [
|
||||
"event_id",
|
||||
"eventId",
|
||||
"message_id",
|
||||
"messageId",
|
||||
]);
|
||||
if (!messageId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function getMessageSendResultRecord(
|
||||
result: string,
|
||||
): Record<string, unknown> | null {
|
||||
const parsed = parseToolResultValue(result);
|
||||
const directRecord = asRecord(parsed);
|
||||
if (getMessageEventId(directRecord)) {
|
||||
return directRecord;
|
||||
}
|
||||
|
||||
const stdout = getToolString(directRecord, ["stdout"]);
|
||||
if (!stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stdoutRecord = asRecord(parseToolResultValue(stdout));
|
||||
return getMessageEventId(stdoutRecord) ? stdoutRecord : null;
|
||||
}
|
||||
|
||||
function getMessageEventId(record: Record<string, unknown>) {
|
||||
return getToolString(record, [
|
||||
"event_id",
|
||||
"eventId",
|
||||
"message_id",
|
||||
"messageId",
|
||||
]);
|
||||
}
|
||||
@@ -1,32 +1,88 @@
|
||||
import * as React from "react";
|
||||
import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react";
|
||||
import { CheckCheck, ChevronDown, Radio } from "lucide-react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
import { ToolItem } from "./AgentSessionToolItem";
|
||||
import { formatTranscriptTime } from "./agentSessionUtils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Toggle } from "@/shared/ui/toggle";
|
||||
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
|
||||
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
|
||||
import {
|
||||
ActivityRow,
|
||||
ActivityRowContent,
|
||||
ActivityRowLabel,
|
||||
type ActivityRowStats,
|
||||
splitActivityRowLabel,
|
||||
} from "./activityRenderClasses/ActivityRow";
|
||||
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
|
||||
import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types";
|
||||
import type { FileEditDiff } from "./agentSessionFileEditDiff";
|
||||
import {
|
||||
buildTranscriptDisplayBlocks,
|
||||
formatTurnSetupLabel,
|
||||
turnSetupDetail,
|
||||
turnSetupTimestamp,
|
||||
type TranscriptDisplayBlock,
|
||||
type TranscriptTurnSegment,
|
||||
} from "./agentSessionTranscriptGrouping";
|
||||
import { buildCompactToolSummary } from "./agentSessionToolSummary";
|
||||
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
|
||||
import { hasFileEditLineDiff } from "./FileEditDiffView";
|
||||
import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";
|
||||
|
||||
const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";
|
||||
|
||||
/**
|
||||
* Opt-in only: source pills are useful while iterating on observer parsing, but
|
||||
* they should not appear for every local dev session.
|
||||
*/
|
||||
const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource();
|
||||
|
||||
function shouldShowTranscriptAcpSource() {
|
||||
const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE;
|
||||
if (envValue === "1" || envValue === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentSessionTranscriptList({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
emptyDescription,
|
||||
items,
|
||||
profiles,
|
||||
}: {
|
||||
agentName: string;
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
emptyDescription: string;
|
||||
items: TranscriptItem[];
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const displayBlocks = React.useMemo(
|
||||
() => buildTranscriptDisplayBlocks(items),
|
||||
[items],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-56 flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<div className="flex min-h-40 flex-col items-center justify-center px-6 py-10 text-center">
|
||||
<Radio className="mx-auto h-4 w-4 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">No ACP activity yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{emptyDescription}</p>
|
||||
@@ -35,224 +91,598 @@ export function AgentSessionTranscriptList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
aria-label="Live ACP transcript"
|
||||
aria-live="polite"
|
||||
className="w-full py-1"
|
||||
className="flex w-full flex-col gap-2.5"
|
||||
role="log"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div className="mt-4 first:mt-0 content-visibility-auto" key={item.id}>
|
||||
<TranscriptItemView
|
||||
{displayBlocks.map((block) => (
|
||||
<div
|
||||
className="content-visibility-auto"
|
||||
key={getDisplayBlockKey(block)}
|
||||
>
|
||||
<TranscriptDisplayBlockView
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
item={item}
|
||||
agentPubkey={agentPubkey}
|
||||
block={block}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptAcpSourceBadge({ source }: { source: string }) {
|
||||
return (
|
||||
<span
|
||||
className="mb-1 inline-flex max-w-full rounded border border-amber-500/25 bg-amber-500/10 px-1.5 py-0.5 font-mono text-xs leading-none text-amber-800 dark:text-amber-200"
|
||||
data-testid="transcript-acp-source"
|
||||
title={`ACP wire source: ${source}`}
|
||||
>
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getDisplayBlockKey(block: TranscriptDisplayBlock) {
|
||||
if (block.kind === "single") {
|
||||
return block.item.id;
|
||||
}
|
||||
return `turn:${block.turnId}`;
|
||||
}
|
||||
|
||||
function TranscriptDisplayBlockView({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
block,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
block: TranscriptDisplayBlock;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
if (block.kind === "single") {
|
||||
return (
|
||||
<TranscriptItemRow
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
item={block.item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2.5"
|
||||
data-testid="transcript-turn-group"
|
||||
data-turn-id={block.turnId}
|
||||
>
|
||||
{block.segments.map((segment) => (
|
||||
<TranscriptTurnSegmentView
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
key={getTurnSegmentKey(block.turnId, segment)}
|
||||
profiles={profiles}
|
||||
segment={segment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
|
||||
if (segment.kind === "setup") {
|
||||
return `turn:${turnId}:setup`;
|
||||
}
|
||||
if (segment.kind === "prompt") {
|
||||
return `turn:${turnId}:prompt`;
|
||||
}
|
||||
if (segment.kind === "summary") {
|
||||
return segment.summary.id;
|
||||
}
|
||||
return segment.item.id;
|
||||
}
|
||||
|
||||
function TranscriptTurnSegmentView({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
profiles,
|
||||
segment,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
profiles?: UserProfileLookup;
|
||||
segment: TranscriptTurnSegment;
|
||||
}) {
|
||||
if (segment.kind === "prompt") {
|
||||
return (
|
||||
<TurnPromptBlock
|
||||
context={segment.context}
|
||||
profiles={profiles}
|
||||
setup={segment.setup}
|
||||
user={segment.user}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (segment.kind === "setup") {
|
||||
return <TurnSetupStatus items={segment.items} />;
|
||||
}
|
||||
|
||||
if (segment.kind === "summary") {
|
||||
return (
|
||||
<SameKindSummaryItem
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
profiles={profiles}
|
||||
summary={segment.summary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TranscriptItemRow
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
item={segment.item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SameKindSummaryItem({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
profiles,
|
||||
summary,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
profiles?: UserProfileLookup;
|
||||
summary: Extract<TranscriptTurnSegment, { kind: "summary" }>["summary"];
|
||||
}) {
|
||||
const groupedFileEditDiffs = React.useMemo(
|
||||
() =>
|
||||
summary.renderClass === "file-edit"
|
||||
? getGroupedFileEditDiffs(summary.items)
|
||||
: [],
|
||||
[summary.items, summary.renderClass],
|
||||
);
|
||||
const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs);
|
||||
const expandsToToolItems = summary.items.every(
|
||||
(item) => item.type === "tool",
|
||||
);
|
||||
|
||||
return (
|
||||
<ActivityRow
|
||||
className="flex flex-col gap-0.5"
|
||||
openToneScope="summary"
|
||||
testId="transcript-same-kind-summary"
|
||||
title={formatTranscriptTimestampTitle(summary.timestamp)}
|
||||
>
|
||||
<ToolRunSummaryLabel label={summary.label} stats={groupedFileEditStats} />
|
||||
<ActivityRowContent
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
expandsToToolItems ? "gap-0.5" : "gap-1 pl-5",
|
||||
)}
|
||||
>
|
||||
{expandsToToolItems
|
||||
? summary.items.map((item) => (
|
||||
<TranscriptItemView
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
item={item}
|
||||
key={item.id}
|
||||
profiles={profiles}
|
||||
/>
|
||||
))
|
||||
: summary.items.map((item) => (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
key={item.id}
|
||||
>
|
||||
{item.type === "tool"
|
||||
? item.descriptor.preview || item.descriptor.label
|
||||
: item.title}
|
||||
</p>
|
||||
))}
|
||||
</ActivityRowContent>
|
||||
</ActivityRow>
|
||||
);
|
||||
}
|
||||
|
||||
function getGroupedFileEditDiffs(items: TranscriptItem[]): FileEditDiff[] {
|
||||
return items.flatMap((item) => {
|
||||
if (item.type !== "tool" || item.isError) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const diff = buildCompactToolSummary(item).fileEditDiff;
|
||||
return diff && hasFileEditLineDiff(diff) ? [diff] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeFileEditDiffs(
|
||||
diffs: FileEditDiff[],
|
||||
): ActivityRowStats | null {
|
||||
if (diffs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return diffs.reduce(
|
||||
(stats, diff) => ({
|
||||
additions: stats.additions + diff.additions,
|
||||
deletions: stats.deletions + diff.deletions,
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function ToolRunSummaryLabel({
|
||||
label,
|
||||
stats,
|
||||
}: {
|
||||
label: string;
|
||||
stats?: ActivityRowStats | null;
|
||||
}) {
|
||||
const parts = splitActivityRowLabel(label);
|
||||
|
||||
if (!parts) {
|
||||
return <span className="truncate text-sm font-medium">{label}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActivityRowLabel
|
||||
object={parts.object}
|
||||
openToneScope="summary"
|
||||
stats={stats}
|
||||
verb={parts.verb}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnPromptBlock({
|
||||
context,
|
||||
profiles,
|
||||
setup,
|
||||
user,
|
||||
}: {
|
||||
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
profiles?: UserProfileLookup;
|
||||
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
user: Extract<TranscriptItem, { type: "message" }>;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="transcript-prompt-bundle">
|
||||
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
<TranscriptAcpSourceBadge source="session/prompt:user" />
|
||||
{context ? (
|
||||
<TranscriptAcpSourceBadge source="session/prompt:context" />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PromptUserMessage
|
||||
context={context}
|
||||
item={user}
|
||||
profiles={profiles}
|
||||
setup={setup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptUserMessage({
|
||||
context = null,
|
||||
item,
|
||||
profiles,
|
||||
setup = [],
|
||||
}: {
|
||||
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
item: Extract<TranscriptItem, { type: "message" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
}) {
|
||||
const [contextOpen, setContextOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserMessageBubble
|
||||
bubbleClassName="p-2.5"
|
||||
footer={
|
||||
<TurnSetupFooter
|
||||
context={context}
|
||||
contextOpen={contextOpen}
|
||||
items={setup}
|
||||
messageLink={getTranscriptMessageLink(item)}
|
||||
onContextOpenChange={setContextOpen}
|
||||
timestamp={item.timestamp}
|
||||
/>
|
||||
}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
<PromptContextDialog
|
||||
context={context}
|
||||
onOpenChange={setContextOpen}
|
||||
open={contextOpen}
|
||||
setup={setup}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptContextSections({
|
||||
className,
|
||||
sections,
|
||||
}: {
|
||||
className?: string;
|
||||
sections: PromptSection[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("space-y-3", className)}
|
||||
data-testid="transcript-prompt-context-sections"
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<PromptContextSectionAccordion
|
||||
key={`${section.title}:${section.body.slice(0, 48)}`}
|
||||
section={section}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptContextSectionAccordion({
|
||||
section,
|
||||
}: {
|
||||
section: PromptSection;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const body = section.body.trim();
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-2xl bg-muted/40">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-semibold text-foreground",
|
||||
!open && "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{section.title}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 text-xs leading-5 text-foreground/70",
|
||||
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
|
||||
)}
|
||||
>
|
||||
{body.length > 0 ? (
|
||||
body
|
||||
) : (
|
||||
<span className="italic text-foreground/50">No metadata.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptContextDialog({
|
||||
context,
|
||||
onOpenChange,
|
||||
open,
|
||||
setup,
|
||||
}: {
|
||||
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
}) {
|
||||
if (!open || !context || context.sections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setupText = formatPromptSetupSummary(setup);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="px-6 pb-3 pt-5 pr-14">
|
||||
<DialogTitle>Prompt context</DialogTitle>
|
||||
{setupText ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCheck className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<DialogDescription>{setupText}</DialogDescription>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 pt-2">
|
||||
<PromptContextSections sections={context.sections} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPromptSetupSummary(
|
||||
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
|
||||
) {
|
||||
const label = formatTurnSetupLabel(items);
|
||||
const detail = turnSetupDetail(items);
|
||||
return [label, detail].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function TurnSetupFooter({
|
||||
context = null,
|
||||
contextOpen = false,
|
||||
items,
|
||||
messageLink = null,
|
||||
onContextOpenChange,
|
||||
showTimestamp = true,
|
||||
timestamp,
|
||||
}: {
|
||||
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
contextOpen?: boolean;
|
||||
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
messageLink?: { channelId: string; messageId: string } | null;
|
||||
onContextOpenChange?: (open: boolean) => void;
|
||||
showTimestamp?: boolean;
|
||||
timestamp: string;
|
||||
}) {
|
||||
const label = formatTurnSetupLabel(items);
|
||||
const detail = turnSetupDetail(items);
|
||||
const tooltipText = [label, detail].filter(Boolean).join(" · ");
|
||||
const showSetup = items.length > 0;
|
||||
const showContext = context != null && context.sections.length > 0;
|
||||
|
||||
if (!showSetup && !showContext) {
|
||||
return showTimestamp ? (
|
||||
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
const contextToggle = showContext ? (
|
||||
<Toggle
|
||||
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
|
||||
data-testid="transcript-prompt-context-toggle"
|
||||
className="data-[state=on]:bg-primary/10 data-[state=on]:text-primary dark:data-[state=on]:bg-primary/15"
|
||||
onPressedChange={onContextOpenChange}
|
||||
pressed={contextOpen}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<CheckCheck aria-hidden="true" />
|
||||
</Toggle>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-muted-foreground/80"
|
||||
data-testid="transcript-turn-setup"
|
||||
>
|
||||
{showContext && showSetup ? contextToggle : null}
|
||||
{!showContext && showSetup ? (
|
||||
<span className="inline-flex shrink-0 items-center justify-center rounded-sm text-muted-foreground/70">
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{tooltipText}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{showContext && !showSetup ? contextToggle : null}
|
||||
{showTimestamp ? (
|
||||
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTranscriptMessageLink(
|
||||
item: Extract<TranscriptItem, { type: "message" }>,
|
||||
) {
|
||||
if (!item.channelId || !item.messageId) return null;
|
||||
return {
|
||||
channelId: item.channelId,
|
||||
messageId: item.messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function TranscriptItemRow({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
item,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
item: TranscriptItem;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? (
|
||||
<TranscriptAcpSourceBadge source={item.acpSource} />
|
||||
) : null}
|
||||
<TranscriptItemView
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnSetupStatus({
|
||||
items,
|
||||
}: {
|
||||
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
}) {
|
||||
const timestamp = turnSetupTimestamp(items);
|
||||
if (items.length === 0 || !timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-md px-2"
|
||||
title={formatTranscriptTimestampTitle(timestamp)}
|
||||
>
|
||||
<TurnSetupFooter
|
||||
items={items}
|
||||
showTimestamp={false}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TranscriptItemView = React.memo(function TranscriptItemView({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
item,
|
||||
profiles,
|
||||
}: {
|
||||
agentName: string;
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
item: TranscriptItem;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
if (item.type === "message") {
|
||||
return (
|
||||
<MessageItem agentName={agentName} item={item} profiles={profiles} />
|
||||
);
|
||||
}
|
||||
if (item.type === "tool") {
|
||||
return <ToolItem item={item} />;
|
||||
}
|
||||
if (item.type === "thought") {
|
||||
return <ThoughtItem item={item} />;
|
||||
}
|
||||
if (item.type === "metadata") {
|
||||
return <MetadataItem item={item} />;
|
||||
}
|
||||
return <LifecycleItem item={item} />;
|
||||
});
|
||||
|
||||
function MessageItem({
|
||||
agentName,
|
||||
item,
|
||||
profiles,
|
||||
}: {
|
||||
agentName: string;
|
||||
item: Extract<TranscriptItem, { type: "message" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const isAssistant = item.role === "assistant";
|
||||
const text = item.text.trim();
|
||||
const authorProfile = item.authorPubkey
|
||||
? profiles?.[item.authorPubkey.toLowerCase()]
|
||||
: null;
|
||||
const authorLabel = item.authorPubkey
|
||||
? resolveUserLabel({
|
||||
pubkey: item.authorPubkey,
|
||||
fallbackName: item.title,
|
||||
profiles,
|
||||
})
|
||||
: item.title || "User";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row px-1 py-1 animate-in fade-in duration-200 motion-reduce:animate-none",
|
||||
)}
|
||||
data-role={isAssistant ? "assistant-message" : "user-message"}
|
||||
>
|
||||
{!isAssistant ? (
|
||||
<UserAvatar
|
||||
avatarUrl={authorProfile?.avatarUrl ?? null}
|
||||
className="mr-2 mt-1 h-5 w-5 shrink-0 text-3xs"
|
||||
displayName={authorLabel}
|
||||
size="xs"
|
||||
<TranscriptActivityItem
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"group relative min-w-0 flex flex-col items-start gap-1",
|
||||
isAssistant ? "w-full" : "max-w-[85%]",
|
||||
)}
|
||||
>
|
||||
{isAssistant ? (
|
||||
<div className="mb-0.5 flex items-center gap-1 text-xs">
|
||||
<span className="flex h-5 w-5 items-center justify-center">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="font-normal text-foreground">{agentName}</span>
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0 text-sm leading-relaxed",
|
||||
!isAssistant && "rounded-2xl bg-muted p-3 text-foreground",
|
||||
)}
|
||||
>
|
||||
{isAssistant ? (
|
||||
<Markdown content={text || " "} />
|
||||
) : (
|
||||
<>
|
||||
<p className="whitespace-pre-wrap break-words">{text}</p>
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThoughtItem({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<TranscriptItem, { type: "thought" }>;
|
||||
}) {
|
||||
return (
|
||||
<details className="group not-prose w-full px-1">
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px text-muted-foreground">
|
||||
<Brain className="h-4 w-4" />
|
||||
<span className="truncate text-sm font-medium">{item.title}</span>
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="py-2 pl-5 text-sm leading-6 text-muted-foreground">
|
||||
<Markdown content={item.text.trim() || " "} />
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataItem({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<TranscriptItem, { type: "metadata" }>;
|
||||
}) {
|
||||
return (
|
||||
<details className="group not-prose w-full px-1">
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px text-muted-foreground">
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
<span className="truncate text-sm font-medium">{item.title}</span>
|
||||
<span className="shrink-0 text-xs">
|
||||
{item.sections.length} section{item.sections.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="space-y-3 py-2 pl-5">
|
||||
{item.sections.map((section) => (
|
||||
<details
|
||||
className="group/section"
|
||||
key={`${section.title}:${section.body.slice(0, 48)}`}
|
||||
>
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-foreground/80">
|
||||
<span className="truncate">{section.title}</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open/section:rotate-180" />
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/50 px-3 py-2 font-mono text-2xs leading-5 text-muted-foreground">
|
||||
{section.body.trim() || "No metadata."}
|
||||
</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecycleItem({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<TranscriptItem, { type: "lifecycle" }>;
|
||||
}) {
|
||||
const isError = item.title.toLowerCase().includes("error");
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-start gap-1.5 px-1 py-2 text-left text-xs",
|
||||
isError ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{item.title}</span>
|
||||
{item.text ? <span> - {item.text}</span> : null}
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fullDateTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
function TranscriptTimestamp({ timestamp }: { timestamp: string }) {
|
||||
const formatted = formatTranscriptTime(timestamp);
|
||||
if (!formatted) return null;
|
||||
const date = new Date(timestamp);
|
||||
const fullDateTime = Number.isNaN(date.getTime())
|
||||
? timestamp
|
||||
: fullDateTimeFormat.format(date);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0 cursor-default text-2xs text-muted-foreground/60">
|
||||
{formatted}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{fullDateTime}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
56
desktop/src/features/agents/ui/FileEditDiffView.tsx
Normal file
56
desktop/src/features/agents/ui/FileEditDiffView.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type {
|
||||
FileEditDiff,
|
||||
FileEditDiffLine,
|
||||
} from "./agentSessionFileEditDiff";
|
||||
|
||||
export function hasFileEditLineDiff(diff: FileEditDiff) {
|
||||
return diff.lines.some(
|
||||
(line) => line.kind === "add" || line.kind === "remove",
|
||||
);
|
||||
}
|
||||
|
||||
export function FileEditDiffBlock({ diff }: { diff: FileEditDiff }) {
|
||||
return (
|
||||
<div className="flex max-h-64 flex-col overflow-hidden rounded-md border border-border/50 bg-muted/35 text-xs leading-5 text-foreground">
|
||||
<pre className="min-h-0 flex-1 overflow-auto py-2 font-mono">
|
||||
<FileEditDiffLines diff={diff} />
|
||||
</pre>
|
||||
<div
|
||||
className="truncate border-t border-border/50 px-3 py-1.5 text-xs font-normal text-muted-foreground/70"
|
||||
title={diff.path}
|
||||
>
|
||||
{diff.path}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditDiffLines({ diff }: { diff: FileEditDiff }) {
|
||||
return diff.lines
|
||||
.filter((line) => line.kind !== "meta")
|
||||
.map((line, index) => (
|
||||
<FileEditDiffLineView
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: diff lines are positional
|
||||
key={index}
|
||||
line={line}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function FileEditDiffLineView({ line }: { line: FileEditDiffLine }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"block min-w-full whitespace-pre-wrap wrap-break-word px-3",
|
||||
line.kind === "add" &&
|
||||
"border-l-2 border-green-500/50 bg-green-500/12 text-foreground dark:bg-green-500/10",
|
||||
line.kind === "remove" &&
|
||||
"border-l-2 border-red-500/50 bg-red-500/12 text-foreground dark:bg-red-500/10",
|
||||
line.kind === "meta" && "text-muted-foreground/70",
|
||||
)}
|
||||
>
|
||||
{line.text || " "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -21,17 +21,28 @@ import type {
|
||||
ObserverEvent,
|
||||
TranscriptItem,
|
||||
} from "./agentSessionTypes";
|
||||
import {
|
||||
deriveLatestSessionId,
|
||||
resolveDisplayEvents,
|
||||
resolveRawRailLayout,
|
||||
scopeByChannel,
|
||||
} from "./agentSessionPanelLayout";
|
||||
import { shorten } from "./agentSessionUtils";
|
||||
import { useObserverEvents, useAgentTranscript } from "./useObserverEvents";
|
||||
|
||||
type ManagedAgentSessionPanelProps = {
|
||||
agent: Pick<ManagedAgent, "pubkey" | "name" | "status">;
|
||||
agent: Pick<ManagedAgent, "pubkey" | "name" | "status"> & {
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
channelId?: string | null;
|
||||
className?: string;
|
||||
emptyDescription?: string;
|
||||
rawLayout?: "responsive" | "exclusive";
|
||||
showHeader?: boolean;
|
||||
showRaw?: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
rawEventsOverride?: ObserverEvent[];
|
||||
transcriptOverride?: TranscriptItem[];
|
||||
};
|
||||
|
||||
export function ManagedAgentSessionPanel({
|
||||
@@ -39,9 +50,12 @@ export function ManagedAgentSessionPanel({
|
||||
channelId = null,
|
||||
className,
|
||||
emptyDescription = "Mention this agent in a channel to watch the next turn.",
|
||||
rawLayout = "responsive",
|
||||
showHeader = true,
|
||||
showRaw = true,
|
||||
profiles,
|
||||
rawEventsOverride,
|
||||
transcriptOverride,
|
||||
}: ManagedAgentSessionPanelProps) {
|
||||
const hasObserver = isManagedAgentActive(agent);
|
||||
const { connectionState, errorMessage, events } = useObserverEvents(
|
||||
@@ -51,27 +65,25 @@ export function ManagedAgentSessionPanel({
|
||||
const transcript = useAgentTranscript(hasObserver, agent.pubkey);
|
||||
|
||||
const scopedTranscript = React.useMemo(
|
||||
() =>
|
||||
channelId
|
||||
? transcript.filter((item) => item.channelId === channelId)
|
||||
: transcript,
|
||||
() => scopeByChannel(transcript, channelId),
|
||||
[channelId, transcript],
|
||||
);
|
||||
|
||||
const displayTranscript = transcriptOverride ?? scopedTranscript;
|
||||
|
||||
const scopedEvents = React.useMemo(
|
||||
() =>
|
||||
channelId
|
||||
? events.filter((event) => event.channelId === channelId)
|
||||
: events,
|
||||
() => scopeByChannel(events, channelId),
|
||||
[channelId, events],
|
||||
);
|
||||
const displayEvents = React.useMemo(
|
||||
() => resolveDisplayEvents(scopedEvents, rawEventsOverride),
|
||||
[rawEventsOverride, scopedEvents],
|
||||
);
|
||||
|
||||
const latestSessionId = React.useMemo(() => {
|
||||
for (let i = scopedEvents.length - 1; i >= 0; i--) {
|
||||
if (scopedEvents[i].sessionId) return scopedEvents[i].sessionId;
|
||||
}
|
||||
return null;
|
||||
}, [scopedEvents]);
|
||||
const latestSessionId = React.useMemo(
|
||||
() => deriveLatestSessionId(displayEvents),
|
||||
[displayEvents],
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -83,22 +95,26 @@ export function ManagedAgentSessionPanel({
|
||||
{showHeader ? (
|
||||
<SessionHeader
|
||||
connectionState={connectionState}
|
||||
eventCount={scopedEvents.length}
|
||||
eventCount={displayEvents.length}
|
||||
hasObserver={hasObserver}
|
||||
latestSessionId={latestSessionId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SessionBody
|
||||
agentAvatarUrl={agent.avatarUrl ?? null}
|
||||
agentName={agent.name}
|
||||
agentPubkey={agent.pubkey}
|
||||
connectionState={connectionState}
|
||||
emptyDescription={emptyDescription}
|
||||
errorMessage={errorMessage}
|
||||
events={scopedEvents}
|
||||
events={displayEvents}
|
||||
hasObserver={hasObserver}
|
||||
hasTranscriptOverride={transcriptOverride != null}
|
||||
profiles={profiles}
|
||||
rawLayout={rawLayout}
|
||||
showRaw={showRaw}
|
||||
transcript={scopedTranscript}
|
||||
transcript={displayTranscript}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
@@ -140,47 +156,76 @@ function SessionHeader({
|
||||
}
|
||||
|
||||
function SessionBody({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
connectionState,
|
||||
emptyDescription,
|
||||
errorMessage,
|
||||
events,
|
||||
hasObserver,
|
||||
hasTranscriptOverride,
|
||||
profiles,
|
||||
rawLayout,
|
||||
showRaw,
|
||||
transcript,
|
||||
}: {
|
||||
agentAvatarUrl: string | null;
|
||||
agentName: string;
|
||||
agentPubkey: string;
|
||||
connectionState: ConnectionState;
|
||||
emptyDescription: string;
|
||||
errorMessage: string | null;
|
||||
events: ObserverEvent[];
|
||||
hasObserver: boolean;
|
||||
hasTranscriptOverride: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
rawLayout: "responsive" | "exclusive";
|
||||
showRaw: boolean;
|
||||
transcript: TranscriptItem[];
|
||||
}) {
|
||||
const rawRail = resolveRawRailLayout(showRaw, rawLayout);
|
||||
|
||||
if (rawRail.mode === "exclusive") {
|
||||
return (
|
||||
<>
|
||||
{!hasObserver ? (
|
||||
<RawEventRail events={events} />
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="mt-4 inline-flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasObserver && !hasTranscriptOverride ? (
|
||||
<EmptyObserverState />
|
||||
) : connectionState === "connecting" && events.length === 0 ? (
|
||||
) : connectionState === "connecting" &&
|
||||
events.length === 0 &&
|
||||
!hasTranscriptOverride ? (
|
||||
<SessionLoadingSkeleton />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
showRaw
|
||||
rawRail.mode === "side"
|
||||
? "mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
||||
: "mt-0",
|
||||
)}
|
||||
>
|
||||
<AgentSessionTranscriptList
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
emptyDescription={emptyDescription}
|
||||
items={transcript}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{showRaw ? <RawEventRail events={events} /> : null}
|
||||
{rawRail.mode === "side" ? <RawEventRail events={events} /> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,46 +1,28 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { ObserverEvent } from "./agentSessionTypes";
|
||||
import { describeRawEvent } from "./agentSessionTranscript";
|
||||
|
||||
export function RawEventRail({ events }: { events: ObserverEvent[] }) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const visible = expanded ? events : events.slice(-18);
|
||||
|
||||
return (
|
||||
<aside className="rounded-lg border border-border/70 bg-[#17171d] text-zinc-100">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
|
||||
<div>
|
||||
<p className="text-2xs font-semibold uppercase tracking-[0.16em] text-zinc-400">
|
||||
Raw ACP
|
||||
<section className="flex min-h-0 w-full flex-col text-foreground">
|
||||
<div className="min-h-0 flex-1">
|
||||
{events.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No raw events yet.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">JSON-RPC payloads</p>
|
||||
</div>
|
||||
<Button
|
||||
className="h-7 text-zinc-300 hover:text-zinc-50"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{expanded ? "Latest" : "All"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[34rem] overflow-auto px-3 py-3">
|
||||
{visible.length === 0 ? (
|
||||
<p className="text-xs text-zinc-500">No raw events yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{visible.map((event) => (
|
||||
{events.map((event) => (
|
||||
<details
|
||||
className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-1.5"
|
||||
className="group rounded-md border border-border/55 bg-muted/25 px-2.5 py-1.5 transition-colors open:bg-muted/35"
|
||||
key={event.seq}
|
||||
>
|
||||
<summary className="cursor-pointer select-none text-xs text-zinc-300">
|
||||
<span className="font-mono text-zinc-500">#{event.seq}</span>{" "}
|
||||
<summary className="cursor-pointer select-none text-xs text-muted-foreground transition-colors group-open:text-foreground">
|
||||
<span className="font-mono text-muted-foreground/70">
|
||||
#{event.seq}
|
||||
</span>{" "}
|
||||
{describeRawEvent(event)}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words text-2xs leading-5 text-zinc-300">
|
||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/40 bg-background/45 p-2 font-mono text-xs leading-5 text-muted-foreground">
|
||||
{JSON.stringify(event.payload, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
@@ -48,6 +30,6 @@ export function RawEventRail({ events }: { events: ObserverEvent[] }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
export type ActivityRowLabelParts = {
|
||||
verb: string;
|
||||
object?: React.ReactNode;
|
||||
};
|
||||
|
||||
export type ActivityRowStats = {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
|
||||
export type ActivityRowToneScope = "none" | "tool" | "summary";
|
||||
|
||||
type ActivityRowProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
openToneScope?: Exclude<ActivityRowToneScope, "none">;
|
||||
testId?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ActivityRowContentProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ACTIVITY_ROW_CONTENT_MARKER = Symbol("ActivityRowContent");
|
||||
|
||||
type ActivityRowContentComponent = React.FC<ActivityRowContentProps> & {
|
||||
marker: typeof ACTIVITY_ROW_CONTENT_MARKER;
|
||||
};
|
||||
|
||||
export function ActivityRow({
|
||||
children,
|
||||
className,
|
||||
openToneScope = "tool",
|
||||
testId,
|
||||
title,
|
||||
}: ActivityRowProps) {
|
||||
const childArray = React.Children.toArray(children);
|
||||
const summaryChildren = childArray.filter(
|
||||
(child) => !isActivityRowContent(child),
|
||||
);
|
||||
const contentChildren = childArray.filter(isActivityRowContent);
|
||||
|
||||
if (contentChildren.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn("not-prose flex min-h-6 items-center gap-1.5", className)}
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<details
|
||||
className={cn(
|
||||
openToneScope === "summary" ? "group/summary" : "group",
|
||||
"not-prose w-full",
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
>
|
||||
<summary
|
||||
className={cn(
|
||||
"inline-flex min-h-6 max-w-full cursor-pointer list-none items-center gap-1.5 text-muted-foreground",
|
||||
openToneScope === "summary"
|
||||
? "group-open/summary:text-foreground"
|
||||
: "group-open:text-foreground",
|
||||
)}
|
||||
>
|
||||
{summaryChildren}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform",
|
||||
openToneScope === "summary"
|
||||
? "group-open/summary:rotate-180 group-open/summary:text-foreground"
|
||||
: "group-open:rotate-180 group-open:text-foreground",
|
||||
)}
|
||||
/>
|
||||
</summary>
|
||||
{contentChildren.map((child, index) => (
|
||||
<div
|
||||
className={child.props.className}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: content regions are static children
|
||||
key={index}
|
||||
>
|
||||
{child.props.children}
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityRowLabel({
|
||||
className,
|
||||
object,
|
||||
openToneScope,
|
||||
stats,
|
||||
title,
|
||||
verb,
|
||||
}: ActivityRowLabelParts & {
|
||||
className?: string;
|
||||
openToneScope: ActivityRowToneScope;
|
||||
stats?: ActivityRowStats | null;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex min-w-0 items-center gap-1.5", className)}
|
||||
title={title}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-sm font-semibold text-muted-foreground/50",
|
||||
openToneScope === "none"
|
||||
? null
|
||||
: openToneScope === "summary"
|
||||
? "group-open/summary:text-foreground"
|
||||
: "group-open:text-foreground",
|
||||
)}
|
||||
>
|
||||
{verb}
|
||||
</span>
|
||||
{object ? (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-normal text-muted-foreground/60",
|
||||
openToneScope === "none"
|
||||
? null
|
||||
: openToneScope === "summary"
|
||||
? "group-open/summary:text-foreground"
|
||||
: "group-open:text-foreground",
|
||||
)}
|
||||
>
|
||||
{object}
|
||||
</span>
|
||||
) : null}
|
||||
{stats ? <ActivityRowStatsView stats={stats} /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const ActivityRowContent = (({ children }: ActivityRowContentProps) => (
|
||||
<>{children}</>
|
||||
)) as ActivityRowContentComponent;
|
||||
ActivityRowContent.marker = ACTIVITY_ROW_CONTENT_MARKER;
|
||||
|
||||
function ActivityRowStatsView({ stats }: { stats: ActivityRowStats }) {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold leading-5 tabular-nums">
|
||||
<span className="text-status-added">+{stats.additions}</span>
|
||||
<span className="text-status-deleted">-{stats.deletions}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function isActivityRowContent(
|
||||
child: React.ReactNode,
|
||||
): child is React.ReactElement<
|
||||
ActivityRowContentProps,
|
||||
ActivityRowContentComponent
|
||||
> {
|
||||
return (
|
||||
React.isValidElement(child) &&
|
||||
typeof child.type !== "string" &&
|
||||
"marker" in child.type &&
|
||||
child.type.marker === ACTIVITY_ROW_CONTENT_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
export function splitActivityRowLabel(
|
||||
label: string,
|
||||
): ActivityRowLabelParts | null {
|
||||
const match = label.match(
|
||||
/^(Added|Archived|Captured|Checked|Compacted|Created|Deleted|Edited|Ran|Read|Removed|Searched|Sent|Unarchived|Updated|Viewed)\s+(.+)$/,
|
||||
);
|
||||
return match ? { verb: match[1], object: match[2] } : null;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { AlertCircle, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
|
||||
import { ActivityRow, ActivityRowLabel } from "./ActivityRow";
|
||||
import { ToolActivity } from "./ToolActivity";
|
||||
import type { ActivityRenderClassItemProps } from "./types";
|
||||
|
||||
export function LifecycleActivity(props: ActivityRenderClassItemProps) {
|
||||
if (props.item.type === "tool") {
|
||||
return <ToolActivity {...props} />;
|
||||
}
|
||||
if (props.item.type !== "lifecycle") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isError =
|
||||
props.item.renderClass === "error" ||
|
||||
props.item.title.toLowerCase().includes("error");
|
||||
const isPermission = props.item.renderClass === "permission";
|
||||
const timestampTitle = formatTranscriptTimestampTitle(props.item.timestamp);
|
||||
|
||||
if (isPermission) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-amber-500/20 bg-amber-500/5 px-2 py-1.5 text-left text-xs text-amber-700 dark:text-amber-400"
|
||||
data-testid="transcript-permission-item"
|
||||
title={timestampTitle}
|
||||
>
|
||||
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
|
||||
<span className="font-medium">{props.item.title}</span>
|
||||
{props.item.text ? (
|
||||
<span className="opacity-80"> · {props.item.text}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-destructive/20 bg-destructive/5 px-2 py-1.5 text-left text-xs text-destructive"
|
||||
data-testid="transcript-lifecycle-item"
|
||||
title={timestampTitle}
|
||||
>
|
||||
<AlertCircle className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
|
||||
<span className="font-medium">{props.item.title}</span>
|
||||
{props.item.text ? (
|
||||
<span className="opacity-80"> · {props.item.text}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ActivityRow testId="transcript-lifecycle-item" title={timestampTitle}>
|
||||
<ActivityRowLabel
|
||||
object={[props.item.title, props.item.text].filter(Boolean).join(" · ")}
|
||||
openToneScope="none"
|
||||
verb="Status"
|
||||
/>
|
||||
</ActivityRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { TranscriptItem } from "../agentSessionTypes";
|
||||
import { ToolActivity } from "./ToolActivity";
|
||||
import { TranscriptTimestamp } from "./TranscriptTimestamp";
|
||||
import type {
|
||||
ActivityRenderClassItemProps,
|
||||
AgentTranscriptIdentityProps,
|
||||
} from "./types";
|
||||
import { UserMessageBubble } from "./UserMessageBubble";
|
||||
|
||||
export function MessageActivity(props: ActivityRenderClassItemProps) {
|
||||
if (props.item.type === "tool") {
|
||||
return <ToolActivity {...props} />;
|
||||
}
|
||||
if (props.item.type !== "message") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
agentAvatarUrl={props.agentAvatarUrl}
|
||||
agentName={props.agentName}
|
||||
agentPubkey={props.agentPubkey}
|
||||
item={props.item}
|
||||
profiles={props.profiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageItem({
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
item,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
item: Extract<TranscriptItem, { type: "message" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const isAssistant = item.role === "assistant";
|
||||
const text = item.text.trim();
|
||||
const messageLink = getTranscriptMessageLink(item);
|
||||
const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null;
|
||||
const assistantLabel = resolveUserLabel({
|
||||
pubkey: agentPubkey,
|
||||
fallbackName: agentName,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const assistantAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl;
|
||||
|
||||
if (!isAssistant) {
|
||||
return (
|
||||
<UserMessageBubble
|
||||
footer={
|
||||
<TranscriptTimestamp
|
||||
messageLink={messageLink}
|
||||
timestamp={item.timestamp}
|
||||
/>
|
||||
}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row animate-in fade-in duration-200 motion-reduce:animate-none"
|
||||
data-role="assistant-message"
|
||||
data-testid="transcript-assistant-message"
|
||||
>
|
||||
<div className="group relative flex w-full min-w-0 flex-col items-start gap-1">
|
||||
<div className="mb-0.5 flex items-center gap-1.5 text-xs">
|
||||
<UserAvatar
|
||||
avatarUrl={assistantAvatarUrl}
|
||||
className="shrink-0"
|
||||
displayName={assistantLabel}
|
||||
size="xs"
|
||||
testId="transcript-assistant-avatar"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{assistantLabel}
|
||||
</span>
|
||||
<TranscriptTimestamp
|
||||
messageLink={messageLink}
|
||||
timestamp={item.timestamp}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full min-w-0 text-sm">
|
||||
<Markdown content={text || " "} tight />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTranscriptMessageLink(
|
||||
item: Extract<TranscriptItem, { type: "message" }>,
|
||||
) {
|
||||
if (!item.channelId || !item.messageId) return null;
|
||||
return {
|
||||
channelId: item.channelId,
|
||||
messageId: item.messageId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import {
|
||||
ActivityRow,
|
||||
ActivityRowContent,
|
||||
ActivityRowLabel,
|
||||
} from "./ActivityRow";
|
||||
import { ToolActivity } from "./ToolActivity";
|
||||
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
|
||||
import type { ActivityRenderClassItemProps } from "./types";
|
||||
|
||||
export function PlanActivity(props: ActivityRenderClassItemProps) {
|
||||
if (props.item.type === "tool") {
|
||||
return <ToolActivity {...props} />;
|
||||
}
|
||||
if (props.item.type !== "plan") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.item.isUpdate) {
|
||||
return (
|
||||
<ActivityRow
|
||||
testId="transcript-plan-update-item"
|
||||
title={formatTranscriptTimestampTitle(props.item.timestamp)}
|
||||
>
|
||||
<ActivityRowLabel
|
||||
object={<PlanUpdateLabelObject text={props.item.text} />}
|
||||
openToneScope="none"
|
||||
verb="Updated"
|
||||
/>
|
||||
</ActivityRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ActivityRow
|
||||
testId="transcript-plan-item"
|
||||
title={formatTranscriptTimestampTitle(props.item.timestamp)}
|
||||
>
|
||||
<ActivityRowLabel object="plan" openToneScope="tool" verb="Updated" />
|
||||
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-5 text-muted-foreground">
|
||||
<Markdown
|
||||
content={props.item.text.trim() || "No plan details."}
|
||||
tight
|
||||
/>
|
||||
</ActivityRowContent>
|
||||
</ActivityRow>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanUpdateLabelObject({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
plan
|
||||
{text ? (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-foreground">{text}</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import {
|
||||
ActivityRow,
|
||||
ActivityRowContent,
|
||||
ActivityRowLabel,
|
||||
} from "./ActivityRow";
|
||||
import { ToolActivity } from "./ToolActivity";
|
||||
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
|
||||
import type { ActivityRenderClassItemProps } from "./types";
|
||||
|
||||
export function RawRailActivity(props: ActivityRenderClassItemProps) {
|
||||
if (props.item.type === "tool") {
|
||||
return <ToolActivity {...props} />;
|
||||
}
|
||||
if (props.item.type !== "metadata") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActivityRow
|
||||
testId="transcript-metadata-item"
|
||||
title={formatTranscriptTimestampTitle(props.item.timestamp)}
|
||||
>
|
||||
<ActivityRowLabel
|
||||
object={`${props.item.sections.length} raw section${
|
||||
props.item.sections.length === 1 ? "" : "s"
|
||||
}`}
|
||||
openToneScope="tool"
|
||||
verb="Captured"
|
||||
/>
|
||||
<ActivityRowContent className="flex flex-col gap-3 py-2">
|
||||
{props.item.sections.map((section) => (
|
||||
<details
|
||||
className="group/section"
|
||||
key={`${section.title}:${section.body.slice(0, 48)}`}
|
||||
>
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-muted-foreground/60 group-open/section:text-foreground">
|
||||
<span className="truncate">{section.title}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 transition-transform group-open/section:rotate-180 group-open/section:text-foreground" />
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap wrap-break-word rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground">
|
||||
{section.body.trim() || "No metadata."}
|
||||
</pre>
|
||||
</details>
|
||||
))}
|
||||
</ActivityRowContent>
|
||||
</ActivityRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
formatTranscriptTimestampTitle,
|
||||
getToolDurationDisplay,
|
||||
} from "../agentSessionUtils";
|
||||
import {
|
||||
ActivityRow,
|
||||
ActivityRowLabel,
|
||||
splitActivityRowLabel,
|
||||
} from "./ActivityRow";
|
||||
import type { ActivityRenderClassItemProps } from "./types";
|
||||
|
||||
export function SuppressedActivity(props: ActivityRenderClassItemProps) {
|
||||
if (props.item.type !== "tool") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const action = props.item.descriptor.action;
|
||||
const labelParts =
|
||||
action ?? splitActivityRowLabel(props.item.descriptor.label);
|
||||
const duration = getToolDurationDisplay(props.item);
|
||||
|
||||
return (
|
||||
<ActivityRow
|
||||
testId="transcript-suppressed-item"
|
||||
title={formatTranscriptTimestampTitle(props.item.timestamp)}
|
||||
>
|
||||
<ActivityRowLabel
|
||||
object={labelParts?.object ?? props.item.descriptor.preview}
|
||||
openToneScope="none"
|
||||
verb={labelParts?.verb ?? props.item.descriptor.label}
|
||||
/>
|
||||
{duration ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground/60">
|
||||
{duration}
|
||||
</span>
|
||||
) : null}
|
||||
</ActivityRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import {
|
||||
ActivityRow,
|
||||
ActivityRowContent,
|
||||
ActivityRowLabel,
|
||||
} from "./ActivityRow";
|
||||
import { ToolActivity } from "./ToolActivity";
|
||||
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
|
||||
import type { ActivityRenderClassItemProps } from "./types";
|
||||
|
||||
export function ThoughtActivity(props: ActivityRenderClassItemProps) {
|
||||
if (props.item.type === "tool") {
|
||||
return <ToolActivity {...props} />;
|
||||
}
|
||||
if (props.item.type !== "thought") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActivityRow
|
||||
testId="transcript-thought-item"
|
||||
title={formatTranscriptTimestampTitle(props.item.timestamp)}
|
||||
>
|
||||
<ActivityRowLabel openToneScope="tool" verb={props.item.title} />
|
||||
<ActivityRowContent className="pt-1 pb-1.5 text-sm leading-6 text-muted-foreground">
|
||||
<Markdown compact content={props.item.text.trim() || " "} />
|
||||
</ActivityRowContent>
|
||||
</ActivityRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ToolItem } from "../AgentSessionToolItem";
|
||||
import type { ActivityRenderClassItemProps } from "./types";
|
||||
|
||||
export function ToolActivity(props: ActivityRenderClassItemProps) {
|
||||
const { item } = props;
|
||||
if (item.type !== "tool") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ToolItem {...props} item={item} />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AgentActivityRenderClass } from "../agentSessionTypes";
|
||||
import { LifecycleActivity } from "./LifecycleActivity";
|
||||
import { MessageActivity } from "./MessageActivity";
|
||||
import { PlanActivity } from "./PlanActivity";
|
||||
import { RawRailActivity } from "./RawRailActivity";
|
||||
import { SuppressedActivity } from "./SuppressedActivity";
|
||||
import { ThoughtActivity } from "./ThoughtActivity";
|
||||
import { ToolActivity } from "./ToolActivity";
|
||||
import type {
|
||||
ActivityRenderClassItemProps,
|
||||
ActivityRenderClassPresenter,
|
||||
} from "./types";
|
||||
|
||||
// Exhaustive render-class routing. Several semantic classes intentionally share
|
||||
// a presenter when their row treatment is the same.
|
||||
export const ACTIVITY_RENDER_CLASS_PRESENTERS = {
|
||||
message: MessageActivity,
|
||||
"relay-op": ToolActivity,
|
||||
"file-edit": ToolActivity,
|
||||
shell: ToolActivity,
|
||||
status: LifecycleActivity,
|
||||
thought: ThoughtActivity,
|
||||
plan: PlanActivity,
|
||||
permission: LifecycleActivity,
|
||||
error: LifecycleActivity,
|
||||
generic: ToolActivity,
|
||||
"raw-rail": RawRailActivity,
|
||||
suppressed: SuppressedActivity,
|
||||
} satisfies Record<AgentActivityRenderClass, ActivityRenderClassPresenter>;
|
||||
|
||||
export function TranscriptActivityItem(props: ActivityRenderClassItemProps) {
|
||||
const Presenter = ACTIVITY_RENDER_CLASS_PRESENTERS[props.item.renderClass];
|
||||
return <Presenter {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { buildMessageLink } from "@/features/messages/lib/messageLink";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
formatTranscriptTime,
|
||||
formatTranscriptTimestampTitle,
|
||||
} from "../agentSessionUtils";
|
||||
|
||||
export type TranscriptTimestampMessageLink = {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
export function TranscriptTimestamp({
|
||||
className,
|
||||
messageLink = null,
|
||||
timestamp,
|
||||
}: {
|
||||
className?: string;
|
||||
messageLink?: TranscriptTimestampMessageLink | null;
|
||||
timestamp: string;
|
||||
}) {
|
||||
const formatted = formatTranscriptTime(timestamp);
|
||||
const { goChannel } = useAppNavigation();
|
||||
const href = messageLink ? buildMessageLink(messageLink) : null;
|
||||
const openMessage = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!messageLink) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void goChannel(messageLink.channelId, {
|
||||
messageId: messageLink.messageId,
|
||||
});
|
||||
},
|
||||
[goChannel, messageLink],
|
||||
);
|
||||
|
||||
if (!formatted) return null;
|
||||
const fullDateTime = formatTranscriptTimestampTitle(timestamp);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
className={cn(
|
||||
"shrink-0 rounded-sm text-xs text-muted-foreground/60 transition-colors hover:text-muted-foreground/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
data-testid="transcript-open-message-link"
|
||||
href={href}
|
||||
onClick={openMessage}
|
||||
title={fullDateTime}
|
||||
>
|
||||
{formatted}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 cursor-default text-xs text-muted-foreground/60",
|
||||
className,
|
||||
)}
|
||||
title={fullDateTime}
|
||||
>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { TranscriptItem } from "../agentSessionTypes";
|
||||
|
||||
export function UserMessageBubble({
|
||||
bubbleClassName,
|
||||
children,
|
||||
className,
|
||||
footer,
|
||||
item,
|
||||
profiles,
|
||||
}: {
|
||||
bubbleClassName?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
footer?: React.ReactNode;
|
||||
item: Extract<TranscriptItem, { type: "message" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const text = item.text.trim();
|
||||
const authorProfile = item.authorPubkey
|
||||
? profiles?.[item.authorPubkey.toLowerCase()]
|
||||
: null;
|
||||
const authorLabel = item.authorPubkey
|
||||
? resolveUserLabel({
|
||||
pubkey: item.authorPubkey,
|
||||
fallbackName: item.title,
|
||||
profiles,
|
||||
})
|
||||
: item.title || "User";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-start justify-end animate-in fade-in duration-200 motion-reduce:animate-none"
|
||||
data-role="user-message"
|
||||
data-testid="transcript-user-message"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={authorProfile?.avatarUrl ?? null}
|
||||
className="order-last ml-2 mt-1 shrink-0"
|
||||
displayName={authorLabel}
|
||||
size="xs"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex max-w-[85%] min-w-0 flex-col items-end gap-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0 rounded-2xl bg-muted p-3 text-sm leading-relaxed text-foreground",
|
||||
bubbleClassName,
|
||||
)}
|
||||
>
|
||||
<Markdown content={text || " "} mediaInset tight />
|
||||
{children}
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { TranscriptItem } from "../agentSessionTypes";
|
||||
|
||||
export type AgentTranscriptIdentityProps = {
|
||||
agentAvatarUrl: string | null;
|
||||
agentName: string;
|
||||
agentPubkey: string;
|
||||
};
|
||||
|
||||
export type ActivityRenderClassItemProps = AgentTranscriptIdentityProps & {
|
||||
item: TranscriptItem;
|
||||
profiles?: UserProfileLookup;
|
||||
};
|
||||
|
||||
export type ActivityRenderClassPresenter =
|
||||
React.ComponentType<ActivityRenderClassItemProps>;
|
||||
203
desktop/src/features/agents/ui/agentSessionFileEditDiff.ts
Normal file
203
desktop/src/features/agents/ui/agentSessionFileEditDiff.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
AgentActivityDescriptor,
|
||||
TranscriptItem,
|
||||
} from "./agentSessionTypes";
|
||||
import {
|
||||
asRecord,
|
||||
getToolString,
|
||||
parseToolResultValue,
|
||||
} from "./agentSessionUtils";
|
||||
|
||||
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
|
||||
|
||||
export type FileEditDiffLineKind = "add" | "remove" | "context" | "meta";
|
||||
|
||||
export type FileEditDiffLine = {
|
||||
kind: FileEditDiffLineKind;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type FileEditDiffSummary = {
|
||||
path: string;
|
||||
filename: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
|
||||
export type FileEditDiff = FileEditDiffSummary & {
|
||||
lines: FileEditDiffLine[];
|
||||
};
|
||||
|
||||
const SHIKI_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/;
|
||||
const SHIKI_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/;
|
||||
|
||||
export function buildFileEditDiff(
|
||||
item: ToolItem,
|
||||
descriptor: AgentActivityDescriptor,
|
||||
): FileEditDiff | null {
|
||||
if (descriptor.renderClass !== "file-edit") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resultText = getResultText(item.result);
|
||||
const path =
|
||||
getToolString(item.args, ["path", "file", "file_path", "target_file"]) ??
|
||||
descriptor.object ??
|
||||
descriptor.preview ??
|
||||
getDiffPath(resultText);
|
||||
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = getDiffLines(resultText);
|
||||
const stats = getDiffStats(resultText, lines);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
filename: basename(path),
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
function getResultText(result: string): string {
|
||||
const parsed = parseToolResultValue(result);
|
||||
if (typeof parsed === "string") {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const record = asRecord(parsed);
|
||||
const output = [
|
||||
getToolString(record, ["stdout", "output", "text"]),
|
||||
getToolString(record, ["stderr"]),
|
||||
]
|
||||
.filter((value): value is string => value != null)
|
||||
.join("\n");
|
||||
|
||||
return output || result;
|
||||
}
|
||||
|
||||
function getDiffPath(text: string): string | null {
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/^\+\+\+\s+(?:b\/)?(.+)$/);
|
||||
if (match?.[1] && match[1] !== "/dev/null") {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match = line.match(/^---\s+(?:a\/)?(.+)$/);
|
||||
if (match?.[1] && match[1] !== "/dev/null") {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDiffLines(text: string): FileEditDiffLine[] {
|
||||
const rawLines = text.split(/\r?\n/);
|
||||
const hasUnifiedDiff = rawLines.some((line) =>
|
||||
/^(diff --git|--- |\+\+\+ |@@)/.test(line),
|
||||
);
|
||||
const lines: FileEditDiffLine[] = [];
|
||||
let inUnifiedDiff = false;
|
||||
|
||||
for (const rawLine of rawLines) {
|
||||
const shikiKind = getShikiDiffKind(rawLine);
|
||||
if (shikiKind) {
|
||||
lines.push({
|
||||
kind: shikiKind,
|
||||
text: rawLine.replace(
|
||||
shikiKind === "add" ? SHIKI_ADD_RE : SHIKI_REMOVE_RE,
|
||||
"",
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasUnifiedDiff && !inUnifiedDiff) {
|
||||
if (!/^(diff --git|--- |\+\+\+ |@@)/.test(rawLine)) {
|
||||
continue;
|
||||
}
|
||||
inUnifiedDiff = true;
|
||||
}
|
||||
|
||||
if (hasUnifiedDiff) {
|
||||
lines.push(classifyUnifiedDiffLine(rawLine));
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push({ kind: "context", text: rawLine });
|
||||
}
|
||||
|
||||
return trimTrailingEmptyContextLines(
|
||||
lines.filter((line) => line.text.length > 0 || line.kind !== "meta"),
|
||||
);
|
||||
}
|
||||
|
||||
function trimTrailingEmptyContextLines(
|
||||
lines: FileEditDiffLine[],
|
||||
): FileEditDiffLine[] {
|
||||
let end = lines.length;
|
||||
while (end > 0) {
|
||||
const line = lines[end - 1];
|
||||
if (line.kind !== "context" || line.text.length > 0) {
|
||||
break;
|
||||
}
|
||||
end -= 1;
|
||||
}
|
||||
return end === lines.length ? lines : lines.slice(0, end);
|
||||
}
|
||||
|
||||
function getShikiDiffKind(line: string): "add" | "remove" | null {
|
||||
if (SHIKI_ADD_RE.test(line)) return "add";
|
||||
if (SHIKI_REMOVE_RE.test(line)) return "remove";
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyUnifiedDiffLine(line: string): FileEditDiffLine {
|
||||
if (/^(diff --git|--- |\+\+\+ |@@)/.test(line)) {
|
||||
return { kind: "meta", text: line };
|
||||
}
|
||||
if (line.startsWith("+")) {
|
||||
return { kind: "add", text: line };
|
||||
}
|
||||
if (line.startsWith("-")) {
|
||||
return { kind: "remove", text: line };
|
||||
}
|
||||
return { kind: "context", text: line };
|
||||
}
|
||||
|
||||
function getDiffStats(
|
||||
text: string,
|
||||
lines: FileEditDiffLine[],
|
||||
): Pick<FileEditDiffSummary, "additions" | "deletions"> | null {
|
||||
const additions = lines.filter((line) => line.kind === "add").length;
|
||||
const deletions = lines.filter((line) => line.kind === "remove").length;
|
||||
|
||||
if (additions > 0 || deletions > 0) {
|
||||
return { additions, deletions };
|
||||
}
|
||||
|
||||
const statAdditions = text.match(/(\d+)\s+insertions?\(\+\)/);
|
||||
const statDeletions = text.match(/(\d+)\s+deletions?\(-\)/);
|
||||
if (statAdditions || statDeletions) {
|
||||
return {
|
||||
additions: statAdditions ? Number(statAdditions[1]) : 0,
|
||||
deletions: statDeletions ? Number(statDeletions[1]) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function basename(path: string) {
|
||||
const parts = path.replace(/\\/g, "/").split("/");
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
102
desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs
Normal file
102
desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
deriveLatestSessionId,
|
||||
resolveDisplayEvents,
|
||||
resolveRawRailLayout,
|
||||
scopeByChannel,
|
||||
} from "./agentSessionPanelLayout.ts";
|
||||
|
||||
// ---- scopeByChannel ----
|
||||
|
||||
const items = [
|
||||
{ id: "a", channelId: "channel-1" },
|
||||
{ id: "b", channelId: "channel-2" },
|
||||
{ id: "c", channelId: "channel-1" },
|
||||
];
|
||||
|
||||
test("scopeByChannel returns the input unchanged when channelId is null", () => {
|
||||
assert.equal(scopeByChannel(items, null), items);
|
||||
});
|
||||
|
||||
test("scopeByChannel returns the input unchanged when channelId is undefined", () => {
|
||||
assert.equal(scopeByChannel(items, undefined), items);
|
||||
});
|
||||
|
||||
test("scopeByChannel filters items down to the requested channel", () => {
|
||||
const scoped = scopeByChannel(items, "channel-1");
|
||||
assert.deepEqual(
|
||||
scoped.map((item) => item.id),
|
||||
["a", "c"],
|
||||
);
|
||||
});
|
||||
|
||||
test("scopeByChannel returns an empty array when no item matches", () => {
|
||||
assert.deepEqual(scopeByChannel(items, "channel-99"), []);
|
||||
});
|
||||
|
||||
// ---- deriveLatestSessionId ----
|
||||
|
||||
test("deriveLatestSessionId returns null for an empty list", () => {
|
||||
assert.equal(deriveLatestSessionId([]), null);
|
||||
});
|
||||
|
||||
test("deriveLatestSessionId returns the last event's sessionId", () => {
|
||||
const events = [
|
||||
{ seq: 1, sessionId: "sess-1" },
|
||||
{ seq: 2, sessionId: "sess-2" },
|
||||
];
|
||||
assert.equal(deriveLatestSessionId(events), "sess-2");
|
||||
});
|
||||
|
||||
test("deriveLatestSessionId skips trailing events without a sessionId", () => {
|
||||
const events = [
|
||||
{ seq: 1, sessionId: "sess-1" },
|
||||
{ seq: 2, sessionId: null },
|
||||
{ seq: 3, sessionId: undefined },
|
||||
];
|
||||
assert.equal(deriveLatestSessionId(events), "sess-1");
|
||||
});
|
||||
|
||||
test("deriveLatestSessionId returns null when no event carries a sessionId", () => {
|
||||
const events = [{ seq: 1, sessionId: null }, { seq: 2 }];
|
||||
assert.equal(deriveLatestSessionId(events), null);
|
||||
});
|
||||
|
||||
// ---- resolveDisplayEvents ----
|
||||
|
||||
test("resolveDisplayEvents returns raw override events unchanged", () => {
|
||||
const scopedEvents = [{ seq: 1, channelId: "channel-1" }];
|
||||
const rawEventsOverride = [{ seq: 2, channelId: "debug-channel" }];
|
||||
assert.equal(
|
||||
resolveDisplayEvents(scopedEvents, rawEventsOverride),
|
||||
rawEventsOverride,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveDisplayEvents falls back to scoped live events", () => {
|
||||
const scopedEvents = [{ seq: 1, channelId: "channel-1" }];
|
||||
assert.equal(resolveDisplayEvents(scopedEvents, undefined), scopedEvents);
|
||||
});
|
||||
|
||||
// ---- resolveRawRailLayout (raw-ACP view toggle) ----
|
||||
|
||||
test("resolveRawRailLayout hides the rail when showRaw is off", () => {
|
||||
assert.deepEqual(resolveRawRailLayout(false, "responsive"), {
|
||||
mode: "hidden",
|
||||
});
|
||||
assert.deepEqual(resolveRawRailLayout(false, "exclusive"), {
|
||||
mode: "hidden",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRawRailLayout renders the rail exclusively when toggled on in exclusive layout", () => {
|
||||
assert.deepEqual(resolveRawRailLayout(true, "exclusive"), {
|
||||
mode: "exclusive",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRawRailLayout renders the rail beside the transcript in responsive layout", () => {
|
||||
assert.deepEqual(resolveRawRailLayout(true, "responsive"), { mode: "side" });
|
||||
});
|
||||
55
desktop/src/features/agents/ui/agentSessionPanelLayout.ts
Normal file
55
desktop/src/features/agents/ui/agentSessionPanelLayout.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ObserverEvent } from "./agentSessionTypes";
|
||||
|
||||
/**
|
||||
* Filter transcript items or raw observer events down to a single channel.
|
||||
* A null `channelId` means "no scoping" — the input is returned as-is.
|
||||
*/
|
||||
export function scopeByChannel<T extends { channelId?: string | null }>(
|
||||
items: readonly T[],
|
||||
channelId: string | null | undefined,
|
||||
): T[] {
|
||||
if (!channelId) return items as T[];
|
||||
return items.filter((item) => item.channelId === channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the most recent session id from a list of observer events by
|
||||
* scanning from the end. Returns null when no event carries a sessionId.
|
||||
*/
|
||||
export function deriveLatestSessionId(
|
||||
events: readonly ObserverEvent[],
|
||||
): string | null {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const sessionId = events[i]?.sessionId;
|
||||
if (sessionId) return sessionId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveDisplayEvents(
|
||||
scopedEvents: ObserverEvent[],
|
||||
rawEventsOverride: ObserverEvent[] | undefined,
|
||||
): ObserverEvent[] {
|
||||
return rawEventsOverride ?? scopedEvents;
|
||||
}
|
||||
|
||||
export type RawRailLayout =
|
||||
| { mode: "hidden" }
|
||||
| { mode: "exclusive" }
|
||||
| { mode: "side" };
|
||||
|
||||
/**
|
||||
* Decide how the raw-ACP event rail should be rendered relative to the
|
||||
* transcript:
|
||||
* - `hidden` — raw view is off
|
||||
* - `exclusive` — raw rail replaces the transcript entirely
|
||||
* - `side` — raw rail renders alongside the transcript (responsive)
|
||||
*/
|
||||
export function resolveRawRailLayout(
|
||||
showRaw: boolean,
|
||||
rawLayout: "responsive" | "exclusive",
|
||||
): RawRailLayout {
|
||||
if (!showRaw) return { mode: "hidden" };
|
||||
if (rawLayout === "exclusive") return { mode: "exclusive" };
|
||||
return { mode: "side" };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
classifyTool,
|
||||
extractSimpleEchoPipeContent,
|
||||
parseBuzzCliCommand,
|
||||
tokenizeShellCommand,
|
||||
} from "./agentSessionToolClassifier.ts";
|
||||
|
||||
test("tokenizeShellCommand preserves quoted strings and command separators", () => {
|
||||
assert.deepEqual(
|
||||
tokenizeShellCommand(
|
||||
'echo "hello world" | buzz messages send --content - --channel agents; buzz feed get',
|
||||
),
|
||||
[
|
||||
"echo",
|
||||
"hello world",
|
||||
"|",
|
||||
"buzz",
|
||||
"messages",
|
||||
"send",
|
||||
"--content",
|
||||
"-",
|
||||
"--channel",
|
||||
"agents",
|
||||
";",
|
||||
"buzz",
|
||||
"feed",
|
||||
"get",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSimpleEchoPipeContent reads the simple echo before a buzz pipe", () => {
|
||||
const tokens = tokenizeShellCommand(
|
||||
'echo -n "Done. Eat my shorts." | buzz messages send --content - --channel agents',
|
||||
);
|
||||
assert.equal(
|
||||
extractSimpleEchoPipeContent(tokens, tokens.indexOf("buzz")),
|
||||
"Done. Eat my shorts.",
|
||||
);
|
||||
});
|
||||
|
||||
test("parseBuzzCliCommand promotes buzz message sends to message descriptors", () => {
|
||||
const descriptor = parseBuzzCliCommand(
|
||||
'echo "Permission wired" | buzz messages send --channel agents --content -',
|
||||
);
|
||||
|
||||
assert.equal(descriptor?.renderClass, "message");
|
||||
assert.equal(descriptor?.label, "Send Message");
|
||||
assert.equal(descriptor?.preview, "Permission wired");
|
||||
assert.equal(descriptor?.operation, "messages.send");
|
||||
});
|
||||
|
||||
test("classifyTool promotes buzz CLI shell commands to relay operations", () => {
|
||||
const descriptor = classifyTool({
|
||||
title: "Shell",
|
||||
toolName: "dev__shell",
|
||||
buzzToolName: null,
|
||||
args: { command: "buzz channels get --channel buzz-agent-observability" },
|
||||
result: "{}",
|
||||
isError: false,
|
||||
});
|
||||
|
||||
assert.equal(descriptor.renderClass, "relay-op");
|
||||
assert.equal(descriptor.label, "Channels Get");
|
||||
assert.equal(descriptor.preview, "buzz-agent-observability");
|
||||
assert.equal(descriptor.groupKey, "buzz-cli:channels.get");
|
||||
});
|
||||
|
||||
test("classifyTool falls back once to a generic descriptor", () => {
|
||||
const descriptor = classifyTool({
|
||||
title: "Mystery",
|
||||
toolName: "mcp__mystery",
|
||||
buzzToolName: null,
|
||||
args: { path: "notes.md" },
|
||||
result: "",
|
||||
isError: false,
|
||||
});
|
||||
|
||||
assert.equal(descriptor.renderClass, "generic");
|
||||
assert.equal(descriptor.label, "Ran tool");
|
||||
assert.equal(descriptor.preview, "notes.md");
|
||||
assert.equal(descriptor.source, "fallback");
|
||||
});
|
||||
639
desktop/src/features/agents/ui/agentSessionToolClassifier.ts
Normal file
639
desktop/src/features/agents/ui/agentSessionToolClassifier.ts
Normal file
@@ -0,0 +1,639 @@
|
||||
import type {
|
||||
AgentActivityAction,
|
||||
AgentActivityDescriptor,
|
||||
AgentActivityRenderClass,
|
||||
AgentActivityTone,
|
||||
TranscriptItem,
|
||||
} from "./agentSessionTypes";
|
||||
import {
|
||||
formatToolTitle,
|
||||
getBuzzToolInfo,
|
||||
normalizeToolNameText,
|
||||
} from "./agentSessionToolCatalog";
|
||||
import {
|
||||
asRecord,
|
||||
getToolString,
|
||||
getToolStringList,
|
||||
} from "./agentSessionUtils";
|
||||
|
||||
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
|
||||
|
||||
export type ToolClassificationInput = {
|
||||
title: string;
|
||||
toolName: string;
|
||||
buzzToolName: string | null;
|
||||
args: Record<string, unknown>;
|
||||
result: string;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
type ToolClassifierProvider = (
|
||||
input: ToolClassificationInput,
|
||||
) => AgentActivityDescriptor | null;
|
||||
|
||||
const DEVELOPER_TOOL_BASES = new Set([
|
||||
"shell",
|
||||
"read_file",
|
||||
"view_image",
|
||||
"str_replace",
|
||||
"todo",
|
||||
"stop",
|
||||
"postcompact",
|
||||
]);
|
||||
|
||||
const BUZZ_CLI_GROUPS = new Set([
|
||||
"messages",
|
||||
"channels",
|
||||
"dms",
|
||||
"reactions",
|
||||
"canvas",
|
||||
"feed",
|
||||
"users",
|
||||
"workflows",
|
||||
"social",
|
||||
"repos",
|
||||
"upload",
|
||||
"mem",
|
||||
"notes",
|
||||
"patches",
|
||||
"pr",
|
||||
"issues",
|
||||
"emoji",
|
||||
"pack",
|
||||
]);
|
||||
|
||||
const BUZZ_CLI_ADMIN_VERBS = new Set([
|
||||
"archive",
|
||||
"unarchive",
|
||||
"create",
|
||||
"delete",
|
||||
"remove",
|
||||
"add-channel-member",
|
||||
"remove-channel-member",
|
||||
"set-channel-add-policy",
|
||||
]);
|
||||
|
||||
const BUZZ_CLI_READ_VERBS = new Set([
|
||||
"get",
|
||||
"list",
|
||||
"thread",
|
||||
"search",
|
||||
"members",
|
||||
"runs",
|
||||
"notes",
|
||||
]);
|
||||
|
||||
const TOOL_CLASS_LABELS: Record<AgentActivityRenderClass, string> = {
|
||||
message: "Message",
|
||||
"relay-op": "Buzz relay op",
|
||||
"file-edit": "File edit",
|
||||
shell: "Shell command",
|
||||
status: "Status",
|
||||
thought: "Thought",
|
||||
plan: "Plan",
|
||||
permission: "Permission",
|
||||
error: "Error",
|
||||
generic: "Tool",
|
||||
"raw-rail": "Raw event",
|
||||
suppressed: "Suppressed",
|
||||
};
|
||||
|
||||
const providers: ToolClassifierProvider[] = [
|
||||
classifyDeveloperHarnessTool,
|
||||
classifyBuzzTool,
|
||||
];
|
||||
|
||||
export function classifyTool(
|
||||
input: ToolClassificationInput,
|
||||
): AgentActivityDescriptor {
|
||||
for (const provider of providers) {
|
||||
const descriptor = provider(input);
|
||||
if (descriptor) {
|
||||
return input.isError || descriptor.renderClass === "error"
|
||||
? {
|
||||
...descriptor,
|
||||
renderClass: "error",
|
||||
label: descriptor.label.endsWith("failed")
|
||||
? descriptor.label
|
||||
: `${descriptor.label} failed`,
|
||||
}
|
||||
: descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
return genericDescriptor(input);
|
||||
}
|
||||
|
||||
export function classifyToolItem(item: ToolItem): AgentActivityDescriptor {
|
||||
return classifyTool({
|
||||
title: item.title,
|
||||
toolName: item.toolName,
|
||||
buzzToolName: item.buzzToolName,
|
||||
args: item.args,
|
||||
result: item.result,
|
||||
isError: item.isError,
|
||||
});
|
||||
}
|
||||
|
||||
export function renderClassLabel(renderClass: AgentActivityRenderClass) {
|
||||
return TOOL_CLASS_LABELS[renderClass];
|
||||
}
|
||||
|
||||
function classifyDeveloperHarnessTool(
|
||||
input: ToolClassificationInput,
|
||||
): AgentActivityDescriptor | null {
|
||||
const kind = resolveDeveloperToolKind(input);
|
||||
if (!kind) return null;
|
||||
|
||||
if (kind === "shell") {
|
||||
const command = getToolString(input.args, ["command"]);
|
||||
const buzzCli = command ? parseBuzzCliCommand(command) : null;
|
||||
if (buzzCli) {
|
||||
return buzzCli;
|
||||
}
|
||||
return {
|
||||
renderClass: "shell",
|
||||
label: "Ran command",
|
||||
preview: command,
|
||||
action: { verb: "Ran", object: command ?? "command" },
|
||||
source: "harness",
|
||||
groupKey: "shell:command",
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "read_file") {
|
||||
const path = getToolString(input.args, ["path"]);
|
||||
return {
|
||||
renderClass: "generic",
|
||||
label: "Read file",
|
||||
preview: path,
|
||||
action: { verb: "Read", object: path ?? "file" },
|
||||
source: "harness",
|
||||
groupKey: "read_file",
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "view_image") {
|
||||
const source = getToolString(input.args, ["source"]);
|
||||
return {
|
||||
renderClass: "generic",
|
||||
label: "Viewed image",
|
||||
preview: source ? basenameOrUrl(source) : null,
|
||||
action: {
|
||||
verb: "Viewed",
|
||||
object: source ? basenameOrUrl(source) : "image",
|
||||
},
|
||||
source: "harness",
|
||||
groupKey: "view_image",
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "str_replace") {
|
||||
const path = getToolString(input.args, ["path"]);
|
||||
return {
|
||||
renderClass: "file-edit",
|
||||
label: "Edited file",
|
||||
preview: path,
|
||||
action: { verb: "Edited", object: path ?? "file" },
|
||||
source: "harness",
|
||||
groupKey: "file-edit:str_replace",
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "todo") {
|
||||
const preview = getTodoPreview(input.args);
|
||||
return {
|
||||
renderClass: "plan",
|
||||
label: "Updated todos",
|
||||
preview,
|
||||
action: { verb: "Updated", object: preview },
|
||||
source: "harness",
|
||||
groupKey: "plan:todo",
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "stop_hook") {
|
||||
return {
|
||||
renderClass: "suppressed",
|
||||
label: "Checked todos",
|
||||
preview: null,
|
||||
action: { verb: "Checked", object: "todos" },
|
||||
source: "harness",
|
||||
groupKey: "suppressed:stop-hook",
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === "post_compact_hook") {
|
||||
return {
|
||||
renderClass: "status",
|
||||
label: "Context compacted",
|
||||
preview: null,
|
||||
action: { verb: "Compacted", object: "context" },
|
||||
source: "harness",
|
||||
groupKey: "status:post-compact",
|
||||
};
|
||||
}
|
||||
|
||||
const preview = genericPreview(input);
|
||||
return {
|
||||
renderClass: "generic",
|
||||
label: "Ran tool",
|
||||
preview,
|
||||
action: { verb: "Ran", object: preview ?? "tool" },
|
||||
source: "harness",
|
||||
groupKey: "generic:dev-mcp",
|
||||
};
|
||||
}
|
||||
|
||||
function classifyBuzzTool(
|
||||
input: ToolClassificationInput,
|
||||
): AgentActivityDescriptor | null {
|
||||
const name = [input.buzzToolName, input.toolName, input.title].find(
|
||||
(value) => value && getBuzzToolInfo(value),
|
||||
);
|
||||
if (!name) return null;
|
||||
|
||||
const info = getBuzzToolInfo(name);
|
||||
if (!info) return null;
|
||||
|
||||
const operation = normalizeToolNameText(name);
|
||||
const label = formatToolTitle(name, input.title);
|
||||
const preview = extractBuzzToolPreview(input.args);
|
||||
return {
|
||||
renderClass: isBuzzMessageSend(operation) ? "message" : "relay-op",
|
||||
label,
|
||||
preview,
|
||||
action: actionForBuzzOperation(operation, preview, info.tone),
|
||||
tone: info.tone,
|
||||
operation,
|
||||
object: preview,
|
||||
source: "mcp",
|
||||
groupKey: `buzz:${operation}`,
|
||||
};
|
||||
}
|
||||
|
||||
function genericDescriptor(
|
||||
input: ToolClassificationInput,
|
||||
): AgentActivityDescriptor {
|
||||
const preview = genericPreview(input);
|
||||
return {
|
||||
renderClass: "generic",
|
||||
label: "Ran tool",
|
||||
preview,
|
||||
action: { verb: "Ran", object: preview ?? "tool" },
|
||||
source: "fallback",
|
||||
groupKey: `generic:${normalizeToolNameText(input.toolName || input.title)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDeveloperToolKind(
|
||||
input: ToolClassificationInput,
|
||||
):
|
||||
| "shell"
|
||||
| "read_file"
|
||||
| "view_image"
|
||||
| "str_replace"
|
||||
| "todo"
|
||||
| "stop_hook"
|
||||
| "post_compact_hook"
|
||||
| "dev_mcp"
|
||||
| null {
|
||||
for (const value of [input.toolName, input.title, input.buzzToolName]) {
|
||||
const kind = classifyDeveloperToolName(value);
|
||||
if (kind) return kind;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyDeveloperToolName(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
|
||||
const normalized = normalizeToolNameText(value);
|
||||
const base = normalized.replace(/^buzz_dev_mcp_/, "");
|
||||
|
||||
if (base === "shell" || normalized.endsWith("_shell")) return "shell";
|
||||
if (base === "read_file" || normalized.endsWith("_read_file"))
|
||||
return "read_file";
|
||||
if (base === "view_image" || normalized.endsWith("_view_image"))
|
||||
return "view_image";
|
||||
if (base === "str_replace" || normalized.endsWith("_str_replace"))
|
||||
return "str_replace";
|
||||
if (base === "todo") return "todo";
|
||||
if (base === "stop") return "stop_hook";
|
||||
if (base === "postcompact") return "post_compact_hook";
|
||||
if (DEVELOPER_TOOL_BASES.has(base) || normalized.includes("buzz_dev_mcp")) {
|
||||
return "dev_mcp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseBuzzCliCommand(
|
||||
command: string,
|
||||
): AgentActivityDescriptor | null {
|
||||
const tokens = tokenizeShellCommand(command);
|
||||
const range = findBuzzCommand(tokens);
|
||||
if (!range) return null;
|
||||
|
||||
const group = tokens[range.groupIndex];
|
||||
const verb = tokens[range.verbIndex] ?? "run";
|
||||
const operation = `${group}.${verb}`;
|
||||
const content =
|
||||
group === "messages" && verb === "send"
|
||||
? extractBuzzCliSendMessageContent(tokens, range)
|
||||
: null;
|
||||
const preview = content ?? extractBuzzCliObjectPreview(tokens, range);
|
||||
const tone = buzzCliTone(group, verb);
|
||||
return {
|
||||
renderClass:
|
||||
group === "messages" && verb === "send" ? "message" : "relay-op",
|
||||
label: titleForBuzzCli(group, verb),
|
||||
preview,
|
||||
action: actionForBuzzOperation(operation, preview, tone),
|
||||
tone,
|
||||
operation,
|
||||
object: preview,
|
||||
source: "shell",
|
||||
groupKey: `buzz-cli:${operation}`,
|
||||
};
|
||||
}
|
||||
|
||||
function titleForBuzzCli(group: string, verb: string) {
|
||||
if (group === "messages" && verb === "send") return "Send Message";
|
||||
return [group, verb]
|
||||
.map((part) =>
|
||||
part
|
||||
.split(/[-_]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" "),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function actionForBuzzOperation(
|
||||
operation: string,
|
||||
object: string | null,
|
||||
tone: AgentActivityTone,
|
||||
): AgentActivityAction {
|
||||
const verb = buzzOperationVerbToken(operation);
|
||||
return {
|
||||
verb: buzzOperationVerb(verb, tone),
|
||||
object: object ?? buzzOperationObject(operation),
|
||||
};
|
||||
}
|
||||
|
||||
function buzzOperationVerbToken(operation: string) {
|
||||
if (operation.includes(".")) {
|
||||
return operation.split(".")[1] ?? "run";
|
||||
}
|
||||
return operation.split("_")[0] ?? "run";
|
||||
}
|
||||
|
||||
function buzzOperationVerb(verb: string, tone: AgentActivityTone) {
|
||||
if (verb === "add") return "Added";
|
||||
if (verb === "archive") return "Archived";
|
||||
if (verb === "create") return "Created";
|
||||
if (verb === "delete") return "Deleted";
|
||||
if (verb === "get" || verb === "list" || verb === "members") return "Read";
|
||||
if (verb === "remove") return "Removed";
|
||||
if (verb === "runs") return "Read";
|
||||
if (verb === "search") return "Searched";
|
||||
if (verb === "send") return "Sent";
|
||||
if (verb === "thread") return "Read";
|
||||
if (verb === "unarchive") return "Unarchived";
|
||||
if (tone === "read") return "Read";
|
||||
return "Updated";
|
||||
}
|
||||
|
||||
function buzzOperationObject(operation: string) {
|
||||
if (isBuzzMessageSend(operation)) return "message";
|
||||
if (operation.includes(".")) {
|
||||
const [group] = operation.split(".");
|
||||
return group ? group.replace(/[-_]+/g, " ") : "Buzz";
|
||||
}
|
||||
const object = operation.replace(
|
||||
/^(add|approve|archive|create|delete|edit|get|hide|join|leave|list|open|publish|remove|search|send|set|trigger|unarchive|update|vote)_/,
|
||||
"",
|
||||
);
|
||||
return object ? object.replace(/[-_]+/g, " ") : "Buzz";
|
||||
}
|
||||
|
||||
function buzzCliTone(group: string, verb: string): AgentActivityTone {
|
||||
if (BUZZ_CLI_ADMIN_VERBS.has(verb)) return "admin";
|
||||
if (BUZZ_CLI_READ_VERBS.has(verb)) return "read";
|
||||
if (group === "feed" && verb === "get") return "read";
|
||||
return "write";
|
||||
}
|
||||
|
||||
function extractBuzzCliSendMessageContent(
|
||||
tokens: string[],
|
||||
range: BuzzCommandRange,
|
||||
): string | null {
|
||||
const content = getFlagValue(tokens, range.verbIndex + 1, "--content");
|
||||
if (!content) return null;
|
||||
if (content !== "-") return content;
|
||||
return extractSimpleEchoPipeContent(tokens, range.buzzIndex) ?? null;
|
||||
}
|
||||
|
||||
function extractBuzzCliObjectPreview(
|
||||
tokens: string[],
|
||||
range: BuzzCommandRange,
|
||||
): string | null {
|
||||
const flagPreview =
|
||||
getFlagValue(tokens, range.verbIndex + 1, "--channel") ??
|
||||
getFlagValue(tokens, range.verbIndex + 1, "--event") ??
|
||||
getFlagValue(tokens, range.verbIndex + 1, "--query") ??
|
||||
getFlagValue(tokens, range.verbIndex + 1, "--name") ??
|
||||
getFlagValue(tokens, range.verbIndex + 1, "--file");
|
||||
if (flagPreview) return flagPreview;
|
||||
|
||||
const next = tokens[range.verbIndex + 1];
|
||||
return next && !isCommandSeparator(next) && !next.startsWith("-")
|
||||
? next
|
||||
: null;
|
||||
}
|
||||
|
||||
type BuzzCommandRange = {
|
||||
buzzIndex: number;
|
||||
groupIndex: number;
|
||||
verbIndex: number;
|
||||
};
|
||||
|
||||
function findBuzzCommand(tokens: string[]): BuzzCommandRange | null {
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (!isBuzzExecutable(tokens[i])) continue;
|
||||
|
||||
for (let j = i + 1; j < tokens.length; j++) {
|
||||
if (isCommandSeparator(tokens[j])) break;
|
||||
if (tokens[j].startsWith("-")) {
|
||||
if (
|
||||
!tokens[j].includes("=") &&
|
||||
tokens[j + 1]?.startsWith("-") === false
|
||||
) {
|
||||
j += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!BUZZ_CLI_GROUPS.has(tokens[j])) continue;
|
||||
const verbIndex = j + 1;
|
||||
if (!tokens[verbIndex] || isCommandSeparator(tokens[verbIndex])) {
|
||||
return null;
|
||||
}
|
||||
return { buzzIndex: i, groupIndex: j, verbIndex };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function tokenizeShellCommand(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaping = false;
|
||||
|
||||
const pushCurrent = () => {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
};
|
||||
|
||||
for (const char of command) {
|
||||
if (escaping) {
|
||||
current += char;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\" && quote !== "'") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
else current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(char)) {
|
||||
pushCurrent();
|
||||
continue;
|
||||
}
|
||||
if (char === "|" || char === ";" || char === "&") {
|
||||
pushCurrent();
|
||||
tokens.push(char);
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (escaping) current += "\\";
|
||||
pushCurrent();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function isBuzzExecutable(token: string) {
|
||||
return token === "buzz" || token.split(/[\\/]/).pop() === "buzz";
|
||||
}
|
||||
|
||||
function isCommandSeparator(token: string) {
|
||||
return token === "|" || token === ";" || token === "&";
|
||||
}
|
||||
|
||||
function getFlagValue(tokens: string[], start: number, flag: string) {
|
||||
for (let i = start; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (isCommandSeparator(token)) return null;
|
||||
if (token === flag) {
|
||||
return tokens[i + 1] && !isCommandSeparator(tokens[i + 1])
|
||||
? tokens[i + 1]
|
||||
: null;
|
||||
}
|
||||
if (token.startsWith(`${flag}=`)) return token.slice(flag.length + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractSimpleEchoPipeContent(
|
||||
tokens: string[],
|
||||
buzzIndex: number,
|
||||
): string | null {
|
||||
const pipeIndex = tokens.lastIndexOf("|", buzzIndex);
|
||||
if (pipeIndex <= 0) return null;
|
||||
const echoStart = findSegmentStart(tokens, pipeIndex - 1);
|
||||
const leftSegment = tokens.slice(echoStart, pipeIndex);
|
||||
if (leftSegment[0] !== "echo") return null;
|
||||
const contentTokens = leftSegment
|
||||
.slice(1)
|
||||
.filter((token) => !token.startsWith("-"));
|
||||
return contentTokens.length > 0 ? contentTokens.join(" ") : null;
|
||||
}
|
||||
|
||||
function findSegmentStart(tokens: string[], beforeIndex: number) {
|
||||
for (let i = beforeIndex; i >= 0; i--) {
|
||||
if (isCommandSeparator(tokens[i])) return i + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractBuzzToolPreview(args: Record<string, unknown>): string | null {
|
||||
const content = getToolString(args, ["content", "message", "text", "body"]);
|
||||
if (content) return content;
|
||||
const query = getToolString(args, ["query", "search"]);
|
||||
if (query) return query;
|
||||
const channelId = getToolString(args, ["channel_id", "channelId"]);
|
||||
if (channelId) return channelId;
|
||||
const workflowId = getToolString(args, ["workflow_id", "workflowId"]);
|
||||
if (workflowId) return workflowId;
|
||||
const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]);
|
||||
if (pubkeys.length === 1) return pubkeys[0];
|
||||
if (pubkeys.length > 1) return `${pubkeys.length} users`;
|
||||
return getToolString(args, ["event_id", "eventId", "name"]);
|
||||
}
|
||||
|
||||
function genericPreview(input: ToolClassificationInput): string | null {
|
||||
return (
|
||||
getToolString(input.args, [
|
||||
"command",
|
||||
"path",
|
||||
"source",
|
||||
"query",
|
||||
"name",
|
||||
"content",
|
||||
"message",
|
||||
]) ?? (input.title ? input.title : null)
|
||||
);
|
||||
}
|
||||
|
||||
function isBuzzMessageSend(operation: string) {
|
||||
return operation === "send_message" || operation === "messages_send";
|
||||
}
|
||||
|
||||
function basenameOrUrl(source: string): string {
|
||||
const trimmed = source.trim();
|
||||
if (
|
||||
trimmed.startsWith("data:image/") ||
|
||||
trimmed.startsWith("http://") ||
|
||||
trimmed.startsWith("https://")
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.split(/[/\\]/).pop() ?? trimmed;
|
||||
}
|
||||
|
||||
function getTodoPreview(args: Record<string, unknown>): string | null {
|
||||
const todos = args.todos;
|
||||
if (!Array.isArray(todos)) return "todo list";
|
||||
if (todos.length === 0) return "empty list";
|
||||
const first = todos[0];
|
||||
const firstText =
|
||||
first && typeof first === "object"
|
||||
? getToolString(asRecord(first), ["text"])
|
||||
: null;
|
||||
if (firstText)
|
||||
return todos.length > 1 ? `${firstText} (+${todos.length - 1})` : firstText;
|
||||
return `${todos.length} item${todos.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
formatDurationMs,
|
||||
formatTranscriptTime,
|
||||
getToolDurationDisplay,
|
||||
isInlineImageData,
|
||||
parseShellToolOutput,
|
||||
parseToolResultValue,
|
||||
} from "./agentSessionUtils.ts";
|
||||
|
||||
// ---- isInlineImageData (dual-layer image-scheme security guard) ----
|
||||
|
||||
test("isInlineImageData accepts data:image/ URIs", () => {
|
||||
const dataUri =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC";
|
||||
assert.equal(isInlineImageData(dataUri), true);
|
||||
});
|
||||
|
||||
test("isInlineImageData rejects non-image data: schemes (no passthrough widening)", () => {
|
||||
// A non-image data: URI must NOT be treated as a safe inline image —
|
||||
// it has to fall through to the relay rewrite path.
|
||||
assert.equal(
|
||||
isInlineImageData(
|
||||
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(isInlineImageData("data:application/json;base64,e30="), false);
|
||||
});
|
||||
|
||||
test("isInlineImageData rejects relay-relative and absolute media URLs", () => {
|
||||
assert.equal(isInlineImageData("/media/abc123.png"), false);
|
||||
assert.equal(isInlineImageData("https://relay.example/media/abc.png"), false);
|
||||
});
|
||||
|
||||
// ---- formatDurationMs ----
|
||||
|
||||
test("formatDurationMs returns null for negative input", () => {
|
||||
assert.equal(formatDurationMs(-1), null);
|
||||
});
|
||||
|
||||
test("formatDurationMs renders sub-10s with one decimal", () => {
|
||||
assert.equal(formatDurationMs(400), "0.4s");
|
||||
assert.equal(formatDurationMs(9900), "9.9s");
|
||||
});
|
||||
|
||||
test("formatDurationMs rounds 10s..60s to whole seconds", () => {
|
||||
assert.equal(formatDurationMs(12300), "12s");
|
||||
assert.equal(formatDurationMs(59400), "59s");
|
||||
});
|
||||
|
||||
test("formatDurationMs renders minutes and seconds", () => {
|
||||
assert.equal(formatDurationMs(90000), "1m 30s");
|
||||
assert.equal(formatDurationMs(120000), "2m");
|
||||
});
|
||||
|
||||
test("formatDurationMs carries a rounded 60s into the next minute", () => {
|
||||
// 89.7s rounds the seconds component to 60, which must carry to 1m 30s
|
||||
assert.equal(formatDurationMs(89700), "1m 30s");
|
||||
});
|
||||
|
||||
test("formatTranscriptTime renders a short 12-hour time without seconds", () => {
|
||||
assert.equal(formatTranscriptTime("2026-06-30T17:00:02.000"), "5:00 PM");
|
||||
});
|
||||
|
||||
// ---- parseToolResultValue (JSON double-parse) ----
|
||||
|
||||
test("parseToolResultValue returns null for empty/whitespace", () => {
|
||||
assert.equal(parseToolResultValue(""), null);
|
||||
assert.equal(parseToolResultValue(" "), null);
|
||||
});
|
||||
|
||||
test("parseToolResultValue parses a JSON object", () => {
|
||||
assert.deepEqual(parseToolResultValue('{"duration_ms":123}'), {
|
||||
duration_ms: 123,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseToolResultValue unwraps a double-encoded JSON string", () => {
|
||||
// The result is a JSON string that itself contains JSON.
|
||||
const doubleEncoded = JSON.stringify(JSON.stringify({ ok: true }));
|
||||
assert.deepEqual(parseToolResultValue(doubleEncoded), { ok: true });
|
||||
});
|
||||
|
||||
test("parseToolResultValue returns the inner string when it is not JSON", () => {
|
||||
const wrapped = JSON.stringify("plain text");
|
||||
assert.equal(parseToolResultValue(wrapped), "plain text");
|
||||
});
|
||||
|
||||
test("parseToolResultValue returns null for invalid JSON", () => {
|
||||
assert.equal(parseToolResultValue("not json {"), null);
|
||||
});
|
||||
|
||||
test("parseShellToolOutput extracts stdout from a shell result envelope", () => {
|
||||
assert.deepEqual(
|
||||
parseShellToolOutput(
|
||||
JSON.stringify({
|
||||
exit_code: 0,
|
||||
stdout: "4 files changed\n",
|
||||
stderr: "",
|
||||
timed_out: false,
|
||||
}),
|
||||
),
|
||||
{
|
||||
exitCode: 0,
|
||||
raw: "",
|
||||
stderr: "",
|
||||
stdout: "4 files changed\n",
|
||||
timedOut: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseShellToolOutput preserves non-envelope output as raw text", () => {
|
||||
assert.deepEqual(parseShellToolOutput(JSON.stringify("plain output")), {
|
||||
exitCode: null,
|
||||
raw: "plain output",
|
||||
stderr: "",
|
||||
stdout: "",
|
||||
timedOut: false,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getToolDurationDisplay (fallback chain) ----
|
||||
|
||||
const startedAt = "2026-06-14T19:00:00.000Z";
|
||||
const completedAt = "2026-06-14T19:00:02.000Z";
|
||||
|
||||
test("getToolDurationDisplay prefers start/complete timestamps", () => {
|
||||
assert.equal(
|
||||
getToolDurationDisplay({ startedAt, completedAt, result: "" }),
|
||||
"2.0s",
|
||||
);
|
||||
});
|
||||
|
||||
test("getToolDurationDisplay falls back to duration_ms in the result payload", () => {
|
||||
assert.equal(
|
||||
getToolDurationDisplay({
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
result: JSON.stringify({ duration_ms: 3500 }),
|
||||
}),
|
||||
"3.5s",
|
||||
);
|
||||
});
|
||||
|
||||
test("getToolDurationDisplay falls back to elapsed_ms when duration_ms absent", () => {
|
||||
assert.equal(
|
||||
getToolDurationDisplay({
|
||||
result: JSON.stringify({ elapsed_ms: 65000 }),
|
||||
}),
|
||||
"1m 5s",
|
||||
);
|
||||
});
|
||||
|
||||
test("getToolDurationDisplay returns null when no duration is available", () => {
|
||||
assert.equal(getToolDurationDisplay({ result: "" }), null);
|
||||
assert.equal(
|
||||
getToolDurationDisplay({ result: JSON.stringify({ other: 1 }) }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
334
desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs
Normal file
334
desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs
Normal file
@@ -0,0 +1,334 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildCompactToolSummary } from "./agentSessionToolSummary.ts";
|
||||
|
||||
const baseTimestamp = "2026-06-14T19:00:00.000Z";
|
||||
|
||||
function makeTool(overrides = {}) {
|
||||
return {
|
||||
id: "tool:1",
|
||||
type: "tool",
|
||||
title: "Tool call",
|
||||
toolName: "shell",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: {},
|
||||
result: "",
|
||||
isError: false,
|
||||
timestamp: baseTimestamp,
|
||||
startedAt: baseTimestamp,
|
||||
completedAt: "2026-06-14T19:00:01.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildCompactToolSummary formats Buzz send_message preview", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "send_message",
|
||||
buzzToolName: "send_message",
|
||||
title: "Send Message",
|
||||
args: { content: "Hello team" },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.kind, "message");
|
||||
assert.equal(summary.label, "Send Message");
|
||||
assert.equal(summary.preview, "Hello team");
|
||||
assert.equal(summary.presentation, "message");
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary treats buzz messages send commands as messages", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "buzz-dev-mcp__shell",
|
||||
args: {
|
||||
command:
|
||||
'buzz --format compact messages send --channel channel-1 --content "@Ned are you working"',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.kind, "message");
|
||||
assert.equal(summary.label, "Send Message");
|
||||
assert.equal(summary.preview, "@Ned are you working");
|
||||
assert.equal(summary.presentation, "message");
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary extracts simple piped buzz message content", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "shell",
|
||||
args: {
|
||||
command:
|
||||
'echo "hello from stdin" | ./target/release/buzz messages send --channel channel-1 --content -',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.label, "Send Message");
|
||||
assert.equal(summary.preview, "hello from stdin");
|
||||
assert.equal(summary.presentation, "message");
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary formats shell command preview", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "buzz-dev-mcp__shell",
|
||||
args: { command: "git status" },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.label, "Ran command");
|
||||
assert.equal(summary.preview, "git status");
|
||||
assert.deepEqual(summary.action, { verb: "Ran", object: "git status" });
|
||||
assert.equal(summary.presentation, "inline");
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary formats view_image thumbnail source", () => {
|
||||
const source =
|
||||
"https://sprout-oss.stage.blox.sqprod.co/media/ffd1b2721f2d52e19f0ca2be9aa7842cdec5b4e0215aaab2a67c26a2a76a6a83.png";
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "buzz-dev-mcp__view_image",
|
||||
args: { source },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.label, "Viewed image");
|
||||
assert.equal(summary.thumbnailSrc, source);
|
||||
assert.equal(summary.preview, source);
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary uses basename for local view_image paths", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "view_image",
|
||||
args: { source: "desktop/assets/screenshot.png" },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.thumbnailSrc, null);
|
||||
assert.equal(summary.preview, "screenshot.png");
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary formats read_file path preview", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "read_file",
|
||||
args: { path: "desktop/src/app/App.tsx" },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.label, "Read file");
|
||||
assert.equal(summary.preview, "desktop/src/app/App.tsx");
|
||||
assert.deepEqual(summary.action, {
|
||||
verb: "Read",
|
||||
object: "desktop/src/app/App.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary formats todo list preview", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "todo",
|
||||
args: {
|
||||
todos: [
|
||||
{ text: "Ship compact summaries", done: false },
|
||||
{ text: "Verify UI", done: false },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.label, "Updated todos");
|
||||
assert.equal(summary.preview, "Ship compact summaries (+1)");
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary uses running and failed labels", () => {
|
||||
assert.equal(
|
||||
buildCompactToolSummary(
|
||||
makeTool({ toolName: "str_replace", status: "executing" }),
|
||||
).label,
|
||||
"Editing file",
|
||||
);
|
||||
assert.equal(
|
||||
buildCompactToolSummary(
|
||||
makeTool({ toolName: "str_replace", status: "failed", isError: true }),
|
||||
).label,
|
||||
"Edit failed",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary promotes non-send buzz CLI commands to relay ops", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "shell",
|
||||
args: {
|
||||
command: "buzz channels get --channel channel-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.kind, "relay-op");
|
||||
assert.equal(summary.label, "Channels Get");
|
||||
assert.equal(summary.preview, "channel-1");
|
||||
assert.deepEqual(summary.action, { verb: "Read", object: "channel-1" });
|
||||
assert.equal(summary.presentation, "inline");
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary derives structured actions for native Buzz MCP tools", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "get_channel",
|
||||
buzzToolName: "get_channel",
|
||||
args: {
|
||||
channel_id: "channel-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.kind, "relay-op");
|
||||
assert.deepEqual(summary.action, { verb: "Read", object: "channel-1" });
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary promotes file edits and todos to first-class classes", () => {
|
||||
assert.equal(
|
||||
buildCompactToolSummary(
|
||||
makeTool({ toolName: "str_replace", args: { path: "src/app.ts" } }),
|
||||
).kind,
|
||||
"file-edit",
|
||||
);
|
||||
assert.equal(
|
||||
buildCompactToolSummary(makeTool({ toolName: "todo", args: { todos: [] } }))
|
||||
.kind,
|
||||
"plan",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary formats file edits as filename plus diff stats", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "str_replace",
|
||||
args: { path: "desktop/src/app/App.tsx" },
|
||||
result: [
|
||||
"Replaced 1 occurrence.",
|
||||
"",
|
||||
"--- a/desktop/src/app/App.tsx",
|
||||
"+++ b/desktop/src/app/App.tsx",
|
||||
"@@",
|
||||
"-<Switch />",
|
||||
"+<DropdownMenuCheckboxItem />",
|
||||
"+<DropdownMenuSeparator />",
|
||||
].join("\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(summary.kind, "file-edit");
|
||||
assert.equal(summary.preview, "App.tsx");
|
||||
assert.deepEqual(summary.fileEditSummary, {
|
||||
path: "desktop/src/app/App.tsx",
|
||||
filename: "App.tsx",
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
});
|
||||
assert.deepEqual(summary.fileEditDiff?.lines, [
|
||||
{ kind: "meta", text: "--- a/desktop/src/app/App.tsx" },
|
||||
{ kind: "meta", text: "+++ b/desktop/src/app/App.tsx" },
|
||||
{ kind: "meta", text: "@@" },
|
||||
{ kind: "remove", text: "-<Switch />" },
|
||||
{ kind: "add", text: "+<DropdownMenuCheckboxItem />" },
|
||||
{ kind: "add", text: "+<DropdownMenuSeparator />" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary counts Shiki diff markers for file edit stats", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "str_replace",
|
||||
args: { path: "desktop/src/app/App.tsx" },
|
||||
result: [
|
||||
"const keep = true;",
|
||||
"const next = true; // [!code ++]",
|
||||
"const old = true; // [!code --]",
|
||||
].join("\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(summary.fileEditSummary, {
|
||||
path: "desktop/src/app/App.tsx",
|
||||
filename: "App.tsx",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
});
|
||||
assert.deepEqual(summary.fileEditDiff?.lines, [
|
||||
{ kind: "context", text: "const keep = true;" },
|
||||
{ kind: "add", text: "const next = true;" },
|
||||
{ kind: "remove", text: "const old = true;" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary parses file edit stats from shell JSON stdout", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "str_replace",
|
||||
args: { path: "desktop/src/app/App.tsx" },
|
||||
result: JSON.stringify({
|
||||
stdout: [
|
||||
"diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx",
|
||||
"--- a/desktop/src/app/App.tsx",
|
||||
"+++ b/desktop/src/app/App.tsx",
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
].join("\n"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(summary.fileEditSummary, {
|
||||
path: "desktop/src/app/App.tsx",
|
||||
filename: "App.tsx",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
});
|
||||
assert.deepEqual(summary.fileEditDiff?.lines, [
|
||||
{
|
||||
kind: "meta",
|
||||
text: "diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx",
|
||||
},
|
||||
{ kind: "meta", text: "--- a/desktop/src/app/App.tsx" },
|
||||
{ kind: "meta", text: "+++ b/desktop/src/app/App.tsx" },
|
||||
{ kind: "meta", text: "@@" },
|
||||
{ kind: "remove", text: "-old" },
|
||||
{ kind: "add", text: "+new" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildCompactToolSummary trims only trailing blank diff lines", () => {
|
||||
const summary = buildCompactToolSummary(
|
||||
makeTool({
|
||||
toolName: "str_replace",
|
||||
args: { path: "desktop/src/app/App.tsx" },
|
||||
result: [
|
||||
"--- a/desktop/src/app/App.tsx",
|
||||
"+++ b/desktop/src/app/App.tsx",
|
||||
"@@",
|
||||
" const before = true;",
|
||||
"",
|
||||
"+const after = true;",
|
||||
"",
|
||||
].join("\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(summary.fileEditDiff?.lines, [
|
||||
{ kind: "meta", text: "--- a/desktop/src/app/App.tsx" },
|
||||
{ kind: "meta", text: "+++ b/desktop/src/app/App.tsx" },
|
||||
{ kind: "meta", text: "@@" },
|
||||
{ kind: "context", text: " const before = true;" },
|
||||
{ kind: "context", text: "" },
|
||||
{ kind: "add", text: "+const after = true;" },
|
||||
]);
|
||||
});
|
||||
112
desktop/src/features/agents/ui/agentSessionToolSummary.ts
Normal file
112
desktop/src/features/agents/ui/agentSessionToolSummary.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
AgentActivityAction,
|
||||
ToolStatus,
|
||||
TranscriptItem,
|
||||
} from "./agentSessionTypes";
|
||||
import type { AgentActivityDescriptor } from "./agentSessionTypes";
|
||||
import { getToolString } from "./agentSessionUtils";
|
||||
import { classifyToolItem } from "./agentSessionToolClassifier";
|
||||
import {
|
||||
buildFileEditDiff,
|
||||
type FileEditDiff,
|
||||
type FileEditDiffSummary,
|
||||
} from "./agentSessionFileEditDiff";
|
||||
|
||||
export type CompactToolKind =
|
||||
| "message"
|
||||
| "relay-op"
|
||||
| "file-edit"
|
||||
| "shell"
|
||||
| "status"
|
||||
| "thought"
|
||||
| "plan"
|
||||
| "permission"
|
||||
| "error"
|
||||
| "generic"
|
||||
| "raw-rail"
|
||||
| "suppressed";
|
||||
|
||||
export type CompactToolSummary = {
|
||||
action: AgentActivityAction | null;
|
||||
kind: CompactToolKind;
|
||||
label: string;
|
||||
preview: string | null;
|
||||
fileEditSummary: FileEditDiffSummary | null;
|
||||
fileEditDiff: FileEditDiff | null;
|
||||
/** When set, the compact row renders a tiny image instead of text preview. */
|
||||
thumbnailSrc: string | null;
|
||||
presentation: "inline" | "message";
|
||||
descriptor: AgentActivityDescriptor;
|
||||
};
|
||||
|
||||
type ToolItem = Extract<TranscriptItem, { type: "tool" }>;
|
||||
|
||||
export type CompactFileEditSummary = FileEditDiffSummary;
|
||||
|
||||
/** Build the muted compact summary label and preview for any tool row. */
|
||||
export function buildCompactToolSummary(item: ToolItem): CompactToolSummary {
|
||||
const descriptor = item.descriptor ?? classifyToolItem(item);
|
||||
const fileEditDiff = buildFileEditDiff(item, descriptor);
|
||||
const fileEditSummary = fileEditDiff
|
||||
? {
|
||||
path: fileEditDiff.path,
|
||||
filename: fileEditDiff.filename,
|
||||
additions: fileEditDiff.additions,
|
||||
deletions: fileEditDiff.deletions,
|
||||
}
|
||||
: null;
|
||||
const thumbnailSrc = getThumbnailSrc(item, descriptor);
|
||||
const failed = item.isError || item.status === "failed";
|
||||
const running = item.status === "executing" || item.status === "pending";
|
||||
return {
|
||||
action: descriptor.action ?? null,
|
||||
kind: descriptor.renderClass,
|
||||
label: labelForStatus(descriptor, item.status, failed, running),
|
||||
preview: fileEditSummary?.filename ?? descriptor.preview,
|
||||
fileEditSummary,
|
||||
fileEditDiff,
|
||||
thumbnailSrc,
|
||||
presentation: descriptor.renderClass === "message" ? "message" : "inline",
|
||||
descriptor,
|
||||
};
|
||||
}
|
||||
|
||||
function labelForStatus(
|
||||
descriptor: AgentActivityDescriptor,
|
||||
status: ToolStatus,
|
||||
failed: boolean,
|
||||
running: boolean,
|
||||
) {
|
||||
const label = descriptor.label;
|
||||
if (descriptor.groupKey === "file-edit:str_replace") {
|
||||
if (failed) return "Edit failed";
|
||||
if (running) return "Editing file";
|
||||
return "Edited file";
|
||||
}
|
||||
if (failed) {
|
||||
return label.endsWith("failed") ? label : `${label} failed`;
|
||||
}
|
||||
if (running) return label;
|
||||
if (status === "completed") return label;
|
||||
return label;
|
||||
}
|
||||
|
||||
function getThumbnailSrc(
|
||||
item: ToolItem,
|
||||
descriptor: AgentActivityDescriptor,
|
||||
): string | null {
|
||||
const operation =
|
||||
descriptor.operation ?? descriptor.groupKey ?? item.toolName;
|
||||
if (!operation.includes("view_image") && item.toolName !== "view_image") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const source = getToolString(item.args, ["source"]);
|
||||
if (!source) return null;
|
||||
const trimmed = source.trim();
|
||||
return trimmed.startsWith("data:image/") ||
|
||||
trimmed.startsWith("http://") ||
|
||||
trimmed.startsWith("https://")
|
||||
? trimmed
|
||||
: null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const baseEvent = {
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
};
|
||||
const PROMPT_EVENT_ID = "c".repeat(64);
|
||||
|
||||
function acpToolUpdate(seq, update) {
|
||||
return {
|
||||
@@ -75,7 +76,7 @@ test("buildTranscript renders Prompt context + user message for a multi-block se
|
||||
{ type: "text", text: "[Context]\nScope: thread" },
|
||||
{
|
||||
type: "text",
|
||||
text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`,
|
||||
text: `[Buzz event: @mention]\nEvent ID: ${PROMPT_EVENT_ID.toUpperCase()}\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -94,6 +95,40 @@ test("buildTranscript renders Prompt context + user message for a multi-block se
|
||||
["Agent Memory — core", "Context", "Buzz event: @mention"],
|
||||
"every section header is counted",
|
||||
);
|
||||
const userMessage = items.find((i) => i.type === "message");
|
||||
assert.equal(userMessage.messageId, PROMPT_EVENT_ID);
|
||||
});
|
||||
|
||||
test("buildTranscript falls back to a single turn trigger id for older prompt frames", () => {
|
||||
const promptEvent = {
|
||||
...baseEvent,
|
||||
seq: 2,
|
||||
payload: {
|
||||
method: "session/prompt",
|
||||
params: {
|
||||
sessionId: "sess-1",
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const [userMessage] = buildTranscript([
|
||||
{
|
||||
...baseEvent,
|
||||
seq: 1,
|
||||
kind: "turn_started",
|
||||
payload: {
|
||||
triggeringEventIds: [PROMPT_EVENT_ID],
|
||||
},
|
||||
},
|
||||
promptEvent,
|
||||
]).filter((candidate) => candidate.type === "message");
|
||||
|
||||
assert.equal(userMessage.messageId, PROMPT_EVENT_ID);
|
||||
});
|
||||
|
||||
test("buildTranscript keeps read_file activity categorized by the actual tool when output names Buzz tools", () => {
|
||||
@@ -194,3 +229,413 @@ test("buildTranscript categorizes explicit Buzz tool calls for the activity bar"
|
||||
assert.deepEqual(item.args, { limit: 20 });
|
||||
assert.equal(item.status, "completed");
|
||||
});
|
||||
|
||||
function sessionUpdate(seq, update, overrides = {}) {
|
||||
return {
|
||||
...baseEvent,
|
||||
...overrides,
|
||||
seq,
|
||||
kind: "acp_read",
|
||||
payload: {
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: overrides.sessionId ?? baseEvent.sessionId,
|
||||
update,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistantChunk(seq, messageId, text, overrides = {}) {
|
||||
return sessionUpdate(
|
||||
seq,
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId,
|
||||
content: { type: "text", text },
|
||||
},
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
test("buildTranscript preserves author pubkeys on user message chunks", () => {
|
||||
const authorPubkey = "b".repeat(64);
|
||||
const [item] = buildTranscript([
|
||||
sessionUpdate(25, {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-chunk-1",
|
||||
authorPubkey,
|
||||
content: { type: "text", text: "please keep this visible" },
|
||||
}),
|
||||
]).filter((candidate) => candidate.type === "message");
|
||||
|
||||
assert.equal(item.role, "user");
|
||||
assert.equal(item.authorPubkey, authorPubkey);
|
||||
assert.equal(item.messageId, null);
|
||||
});
|
||||
|
||||
test("buildTranscript preserves real event ids on user message chunks", () => {
|
||||
const authorPubkey = "b".repeat(64);
|
||||
const messageId = "d".repeat(64);
|
||||
const [item] = buildTranscript([
|
||||
sessionUpdate(26, {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId,
|
||||
authorPubkey,
|
||||
content: { type: "text", text: "this came from a channel message" },
|
||||
}),
|
||||
]).filter((candidate) => candidate.type === "message");
|
||||
|
||||
assert.equal(item.role, "user");
|
||||
assert.equal(item.messageId, messageId);
|
||||
});
|
||||
|
||||
test("buildTranscript de-duplicates repeated tool updates into one canonical row", () => {
|
||||
const items = toolItems([
|
||||
acpToolUpdate(40, {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "call-dupe",
|
||||
status: "executing",
|
||||
title: "shell",
|
||||
kind: "shell",
|
||||
rawInput: { command: "echo hi" },
|
||||
}),
|
||||
acpToolUpdate(41, {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-dupe",
|
||||
status: "completed",
|
||||
title: "shell",
|
||||
kind: "shell",
|
||||
rawOutput: "hi",
|
||||
}),
|
||||
acpToolUpdate(42, {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-dupe",
|
||||
status: "completed",
|
||||
title: "shell",
|
||||
kind: "shell",
|
||||
rawOutput: "hi",
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].id, `tool:${baseEvent.channelId}:call-dupe`);
|
||||
assert.equal(items[0].status, "completed");
|
||||
assert.equal(items[0].result, "hi");
|
||||
});
|
||||
|
||||
test("buildTranscript keeps a completed tool terminal when a late executing call arrives", () => {
|
||||
const [item] = toolItems([
|
||||
acpToolUpdate(50, {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-regression",
|
||||
status: "completed",
|
||||
title: "shell",
|
||||
kind: "shell",
|
||||
rawOutput: "done",
|
||||
}),
|
||||
acpToolUpdate(51, {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "call-regression",
|
||||
status: "executing",
|
||||
title: "shell",
|
||||
kind: "shell",
|
||||
rawInput: { command: "echo done" },
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(item.status, "completed");
|
||||
assert.equal(item.completedAt, baseEvent.timestamp);
|
||||
assert.deepEqual(item.args, { command: "echo done" });
|
||||
assert.equal(item.result, "done");
|
||||
});
|
||||
|
||||
test("buildTranscript rebuilds out-of-order tool frames as one canonical row with retained ids", () => {
|
||||
const [item] = toolItems([
|
||||
sessionUpdate(
|
||||
60,
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "call-out-of-order",
|
||||
status: "completed",
|
||||
title: "read_file",
|
||||
kind: "read_file",
|
||||
rawOutput: "file contents",
|
||||
},
|
||||
{
|
||||
channelId: "22222222-2222-2222-2222-222222222222",
|
||||
sessionId: "sess-2",
|
||||
turnId: "turn-2",
|
||||
timestamp: "2026-06-18T00:00:05Z",
|
||||
},
|
||||
),
|
||||
sessionUpdate(
|
||||
61,
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "call-out-of-order",
|
||||
status: "executing",
|
||||
title: "read_file",
|
||||
kind: "read_file",
|
||||
rawInput: { path: "AGENTS.md" },
|
||||
},
|
||||
{
|
||||
channelId: "22222222-2222-2222-2222-222222222222",
|
||||
sessionId: "sess-2",
|
||||
turnId: "turn-2",
|
||||
timestamp: "2026-06-18T00:00:04Z",
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
item.id,
|
||||
"tool:22222222-2222-2222-2222-222222222222:call-out-of-order",
|
||||
);
|
||||
assert.equal(item.status, "completed");
|
||||
assert.deepEqual(item.args, { path: "AGENTS.md" });
|
||||
assert.equal(item.channelId, "22222222-2222-2222-2222-222222222222");
|
||||
assert.equal(item.turnId, "turn-2");
|
||||
assert.equal(item.sessionId, "sess-2");
|
||||
});
|
||||
|
||||
test("buildTranscript coalesces assistant chunks until the message is sealed", () => {
|
||||
const messages = buildTranscript([
|
||||
assistantChunk(70, "msg-1", "Hello "),
|
||||
assistantChunk(71, "msg-1", "world"),
|
||||
]).filter((item) => item.type === "message" && item.role === "assistant");
|
||||
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].text, "Hello world");
|
||||
assert.equal(messages[0].id, `assistant:${baseEvent.channelId}:msg-1`);
|
||||
});
|
||||
|
||||
test("buildTranscript starts a continuation for same-message chunks after sealing", () => {
|
||||
const messages = buildTranscript([
|
||||
assistantChunk(80, "msg-2", "First"),
|
||||
acpToolUpdate(81, {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "call-seal",
|
||||
status: "executing",
|
||||
title: "shell",
|
||||
kind: "shell",
|
||||
}),
|
||||
assistantChunk(82, "msg-2", "Second"),
|
||||
]).filter((item) => item.type === "message" && item.role === "assistant");
|
||||
|
||||
assert.equal(messages.length, 2);
|
||||
assert.equal(messages[0].text, "First");
|
||||
assert.equal(messages[1].text, "Second");
|
||||
assert.match(messages[1].id, /:c\d+$/);
|
||||
});
|
||||
|
||||
test("buildTranscript preserves channel, turn, and session ids through message updates", () => {
|
||||
const [message] = buildTranscript([
|
||||
assistantChunk(90, "msg-identity", "One ", {
|
||||
channelId: "33333333-3333-3333-3333-333333333333",
|
||||
sessionId: "sess-identity",
|
||||
turnId: "turn-identity",
|
||||
}),
|
||||
assistantChunk(91, "msg-identity", "Two", {
|
||||
channelId: "33333333-3333-3333-3333-333333333333",
|
||||
sessionId: null,
|
||||
turnId: null,
|
||||
}),
|
||||
]).filter((item) => item.type === "message" && item.role === "assistant");
|
||||
|
||||
assert.equal(message.text, "One Two");
|
||||
assert.equal(message.channelId, "33333333-3333-3333-3333-333333333333");
|
||||
assert.equal(message.turnId, "turn-identity");
|
||||
assert.equal(message.sessionId, "sess-identity");
|
||||
});
|
||||
|
||||
test("buildTranscript promotes ACP plan updates to first-class plan items", () => {
|
||||
const items = buildTranscript([
|
||||
sessionUpdate(90, {
|
||||
sessionUpdate: "plan",
|
||||
content: { type: "text", text: "- [ ] Build registry" },
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].type, "plan");
|
||||
assert.equal(items[0].renderClass, "plan");
|
||||
assert.match(items[0].text, /Build registry/);
|
||||
});
|
||||
|
||||
test("buildTranscript stores first-class render class descriptors for tool items", () => {
|
||||
const [item] = toolItems([
|
||||
acpToolUpdate(91, {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "call-edit",
|
||||
status: "completed",
|
||||
title: "str_replace",
|
||||
kind: "str_replace",
|
||||
rawInput: { path: "src/app.ts" },
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(item.renderClass, "file-edit");
|
||||
assert.equal(item.descriptor.label, "Edited file");
|
||||
assert.equal(item.descriptor.preview, "src/app.ts");
|
||||
});
|
||||
|
||||
test("buildTranscript surfaces session/request_permission as a permission lifecycle item", () => {
|
||||
const transcript = buildTranscript([
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-06-30T09:00:00.000Z",
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: "channel-1",
|
||||
sessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
toolCallId: "tool-1",
|
||||
title: "Confirm force-with-lease push to block/buzz.",
|
||||
options: [
|
||||
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
|
||||
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(transcript.length, 1);
|
||||
assert.equal(transcript[0].type, "lifecycle");
|
||||
assert.equal(transcript[0].renderClass, "permission");
|
||||
assert.equal(transcript[0].title, "Permission requested");
|
||||
assert.match(transcript[0].text, /Confirm force-with-lease push/);
|
||||
});
|
||||
|
||||
test("buildTranscript stamps completedAt when a terminal tool update is inserted first", () => {
|
||||
const transcript = buildTranscript([
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-06-30T09:00:00.000Z",
|
||||
kind: "acp_read",
|
||||
agentIndex: 0,
|
||||
channelId: "channel-1",
|
||||
sessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "dev__shell",
|
||||
status: "completed",
|
||||
rawInput: { command: "echo hi" },
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(transcript[0].type, "tool");
|
||||
assert.equal(transcript[0].completedAt, "2026-06-30T09:00:00.000Z");
|
||||
});
|
||||
|
||||
test("buildTranscript preserves permission, free-form status, and raw rail render classes", () => {
|
||||
const transcript = buildTranscript([
|
||||
{
|
||||
...baseEvent,
|
||||
seq: 1,
|
||||
kind: "acp_read",
|
||||
payload: {
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
title: "Confirm force-with-lease push",
|
||||
toolCallId: "tool-push",
|
||||
options: [
|
||||
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
|
||||
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...baseEvent,
|
||||
seq: 2,
|
||||
kind: "acp_read",
|
||||
payload: {
|
||||
type: "observer_connected",
|
||||
title: "Observer connected",
|
||||
text: "ACP stream attached",
|
||||
},
|
||||
},
|
||||
{
|
||||
...baseEvent,
|
||||
seq: 3,
|
||||
kind: "raw_json_rpc",
|
||||
payload: {
|
||||
method: "workspace/diagnostic",
|
||||
params: { ok: true },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const permissionItem = transcript.find(
|
||||
(item) =>
|
||||
item.id.startsWith("permission:") && item.renderClass === "permission",
|
||||
);
|
||||
assert.ok(
|
||||
permissionItem,
|
||||
"permission request should flow through the reducer",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
permissionItem.text,
|
||||
/^Permission requested\b/,
|
||||
"permission detail should not duplicate the row title",
|
||||
);
|
||||
assert.ok(
|
||||
transcript.some(
|
||||
(item) =>
|
||||
item.renderClass === "status" && item.title === "Observer connected",
|
||||
),
|
||||
"free-form status fixture should flow through the reducer",
|
||||
);
|
||||
assert.ok(
|
||||
transcript.some(
|
||||
(item) => item.type === "metadata" && item.renderClass === "raw-rail",
|
||||
),
|
||||
"raw_json_rpc fixture should flow through the reducer",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildTranscript separates repeated lifecycle text", () => {
|
||||
const events = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-06-30T09:00:00.000Z",
|
||||
kind: "turn_error",
|
||||
agentIndex: 0,
|
||||
channelId: "channel-1",
|
||||
sessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
payload: { outcome: "recovered", error: "first" },
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-06-30T09:00:01.000Z",
|
||||
kind: "turn_error",
|
||||
agentIndex: 0,
|
||||
channelId: "channel-1",
|
||||
sessionId: "session-1",
|
||||
turnId: "turn-1",
|
||||
payload: { outcome: "recovered", error: "second" },
|
||||
},
|
||||
];
|
||||
|
||||
const [item] = buildTranscript(events);
|
||||
assert.equal(item.type, "lifecycle");
|
||||
assert.equal(item.text, "recovered: first\nrecovered: second");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
AgentActivityDescriptor,
|
||||
AgentActivityRenderClass,
|
||||
ObserverEvent,
|
||||
PromptSection,
|
||||
ToolStatus,
|
||||
@@ -9,13 +11,15 @@ import {
|
||||
isGenericToolTitle,
|
||||
normalizeToolStatus,
|
||||
} from "./agentSessionToolCatalog";
|
||||
import { asRecord, asString } from "./agentSessionUtils";
|
||||
import { classifyTool } from "./agentSessionToolClassifier";
|
||||
import { asRecord, asString, titleCase } from "./agentSessionUtils";
|
||||
import {
|
||||
describeTurnStarted,
|
||||
describeSessionResolved,
|
||||
extractBlockText,
|
||||
extractContentText,
|
||||
extractPromptText,
|
||||
extractTriggeringEventIds,
|
||||
extractToolArgs,
|
||||
extractToolIdentity,
|
||||
extractToolResult,
|
||||
@@ -30,6 +34,7 @@ export type TranscriptState = {
|
||||
itemsById: Map<string, TranscriptItem>;
|
||||
activeMessageKey: Map<string, string>;
|
||||
sealedKeys: Set<string>;
|
||||
triggeringEventIdsByTurn: Map<string, string[]>;
|
||||
continuationSeq: number;
|
||||
latestSessionId: string | null;
|
||||
};
|
||||
@@ -40,6 +45,7 @@ export function createEmptyTranscriptState(): TranscriptState {
|
||||
itemsById: new Map(),
|
||||
activeMessageKey: new Map(),
|
||||
sealedKeys: new Set(),
|
||||
triggeringEventIdsByTurn: new Map(),
|
||||
continuationSeq: 0,
|
||||
latestSessionId: null,
|
||||
};
|
||||
@@ -55,6 +61,7 @@ type TranscriptDraft = {
|
||||
itemsById: Map<string, TranscriptItem>;
|
||||
activeMessageKey: Map<string, string>;
|
||||
sealedKeys: Set<string>;
|
||||
triggeringEventIdsByTurn: Map<string, string[]>;
|
||||
continuationSeq: number;
|
||||
latestSessionId: string | null;
|
||||
changed: boolean;
|
||||
@@ -66,6 +73,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft {
|
||||
itemsById: state.itemsById,
|
||||
activeMessageKey: state.activeMessageKey,
|
||||
sealedKeys: state.sealedKeys,
|
||||
triggeringEventIdsByTurn: state.triggeringEventIdsByTurn,
|
||||
continuationSeq: state.continuationSeq,
|
||||
latestSessionId: state.latestSessionId,
|
||||
changed: false,
|
||||
@@ -109,6 +117,104 @@ function sealOpenMessages(d: TranscriptDraft) {
|
||||
}
|
||||
}
|
||||
|
||||
function turnMapKey(channelKey: string, turnKey: string | number | null) {
|
||||
return `${channelKey}:${turnKey ?? "unknown"}`;
|
||||
}
|
||||
|
||||
function rememberTriggeringEventIds(
|
||||
d: TranscriptDraft,
|
||||
channelKey: string,
|
||||
turnKey: string | number | null,
|
||||
ids: string[],
|
||||
) {
|
||||
if (ids.length === 0) return;
|
||||
d.triggeringEventIdsByTurn = new Map(d.triggeringEventIdsByTurn);
|
||||
d.triggeringEventIdsByTurn.set(turnMapKey(channelKey, turnKey), ids);
|
||||
}
|
||||
|
||||
function getSingleTriggeringEventId(
|
||||
d: TranscriptDraft,
|
||||
channelKey: string,
|
||||
turnKey: string | number | null,
|
||||
) {
|
||||
const ids = d.triggeringEventIdsByTurn.get(turnMapKey(channelKey, turnKey));
|
||||
return ids?.length === 1 ? maybeNostrEventId(ids[0]) : null;
|
||||
}
|
||||
|
||||
function maybeNostrEventId(id: string | null | undefined) {
|
||||
return id && /^[0-9a-fA-F]{64}$/.test(id) ? id : null;
|
||||
}
|
||||
|
||||
function stringifyPayload(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function describePermissionRequest(payload: Record<string, unknown>) {
|
||||
const params = asRecord(payload.params);
|
||||
const title =
|
||||
asString(params.title) ??
|
||||
asString(params.message) ??
|
||||
asString(params.reason) ??
|
||||
"Permission requested";
|
||||
const toolCallId =
|
||||
asString(params.toolCallId) ?? asString(params.tool_call_id);
|
||||
const options = Array.isArray(params.options)
|
||||
? params.options
|
||||
.map((option) => {
|
||||
const record = asRecord(option);
|
||||
return (
|
||||
asString(record.name) ??
|
||||
asString(record.kind) ??
|
||||
asString(record.optionId)
|
||||
);
|
||||
})
|
||||
.filter((option): option is string => Boolean(option))
|
||||
: [];
|
||||
const detail: string[] = [];
|
||||
if (title !== "Permission requested") detail.push(title);
|
||||
if (toolCallId) detail.push(`Tool call: ${toolCallId}`);
|
||||
if (options.length > 0) detail.push(`Options: ${options.join(", ")}`);
|
||||
return {
|
||||
title,
|
||||
text: detail.join("\n"),
|
||||
descriptor: {
|
||||
renderClass: "permission" as const,
|
||||
label: "Permission requested",
|
||||
preview: title,
|
||||
action: { verb: "Requested", object: title },
|
||||
tone: "admin" as const,
|
||||
operation: "session/request_permission",
|
||||
object: title,
|
||||
source: "acp" as const,
|
||||
groupKey: "permission:request",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function describeFreeformStatus(payload: Record<string, unknown>) {
|
||||
const statusType = asString(payload.type) ?? asString(payload.status);
|
||||
const title =
|
||||
asString(payload.title) ?? (statusType ? titleCase(statusType) : null);
|
||||
const text = asString(payload.text) ?? asString(payload.message);
|
||||
if (!title || !text) return null;
|
||||
return { statusType: statusType ?? title.toLowerCase(), title, text };
|
||||
}
|
||||
|
||||
function rawPayloadTitle(payload: unknown) {
|
||||
const record = asRecord(payload);
|
||||
return asString(record.method) ?? asString(record.type) ?? "raw_json_rpc";
|
||||
}
|
||||
|
||||
type TranscriptItemContext = {
|
||||
channelId: string | null;
|
||||
turnId: string | null;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
function upsertMessage(
|
||||
d: TranscriptDraft,
|
||||
id: string,
|
||||
@@ -116,8 +222,10 @@ function upsertMessage(
|
||||
title: string,
|
||||
text: string,
|
||||
timestamp: string,
|
||||
channelId: string | null,
|
||||
ctx: TranscriptItemContext,
|
||||
authorPubkey: string | null = null,
|
||||
acpSource?: string,
|
||||
messageId: string | null = null,
|
||||
) {
|
||||
const currentKey = d.activeMessageKey.get(id);
|
||||
|
||||
@@ -127,8 +235,12 @@ function upsertMessage(
|
||||
replaceItem(d, currentKey, {
|
||||
...existing,
|
||||
text: existing.text + text,
|
||||
channelId,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId ?? existing.turnId,
|
||||
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||
authorPubkey: authorPubkey ?? existing.authorPubkey,
|
||||
acpSource: acpSource ?? existing.acpSource,
|
||||
messageId: messageId ?? existing.messageId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -139,12 +251,17 @@ function upsertMessage(
|
||||
pushItem(d, {
|
||||
id: newKey,
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role,
|
||||
title,
|
||||
text,
|
||||
timestamp,
|
||||
channelId,
|
||||
messageId,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId,
|
||||
sessionId: ctx.sessionId,
|
||||
authorPubkey,
|
||||
acpSource,
|
||||
});
|
||||
d.activeMessageKey = new Map(d.activeMessageKey);
|
||||
d.activeMessageKey.set(id, newKey);
|
||||
@@ -157,15 +274,172 @@ function upsertTextItem(
|
||||
title: string,
|
||||
text: string,
|
||||
timestamp: string,
|
||||
channelId: string | null,
|
||||
ctx: TranscriptItemContext,
|
||||
acpSource?: string,
|
||||
) {
|
||||
const existing = d.itemsById.get(id);
|
||||
if (existing && existing.type === type) {
|
||||
replaceItem(d, id, { ...existing, text: existing.text + text, channelId });
|
||||
replaceItem(d, id, {
|
||||
...existing,
|
||||
text:
|
||||
type === "lifecycle"
|
||||
? joinLifecycleText(existing.text, text)
|
||||
: existing.text + text,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId ?? existing.turnId,
|
||||
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||
acpSource: acpSource ?? existing.acpSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sealOpenMessages(d);
|
||||
pushItem(d, { id, type, title, text, timestamp, channelId });
|
||||
if (type === "thought") {
|
||||
pushItem(d, {
|
||||
id,
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title,
|
||||
text,
|
||||
timestamp,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId,
|
||||
sessionId: ctx.sessionId,
|
||||
acpSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
upsertLifecycleItem(
|
||||
d,
|
||||
id,
|
||||
title.toLowerCase().includes("error") ? "error" : "status",
|
||||
title,
|
||||
text,
|
||||
timestamp,
|
||||
ctx,
|
||||
acpSource,
|
||||
);
|
||||
}
|
||||
|
||||
function joinLifecycleText(existing: string, next: string) {
|
||||
if (!existing) return next;
|
||||
if (!next) return existing;
|
||||
return `${existing}\n${next}`;
|
||||
}
|
||||
|
||||
function upsertLifecycleItem(
|
||||
d: TranscriptDraft,
|
||||
id: string,
|
||||
renderClass: Extract<
|
||||
AgentActivityRenderClass,
|
||||
"status" | "permission" | "error"
|
||||
>,
|
||||
title: string,
|
||||
text: string,
|
||||
timestamp: string,
|
||||
ctx: TranscriptItemContext,
|
||||
acpSource?: string,
|
||||
descriptor?: AgentActivityDescriptor,
|
||||
) {
|
||||
const existing = d.itemsById.get(id);
|
||||
if (existing?.type === "lifecycle") {
|
||||
replaceItem(d, id, {
|
||||
...existing,
|
||||
renderClass,
|
||||
title,
|
||||
text: joinLifecycleText(existing.text, text),
|
||||
descriptor: descriptor ?? existing.descriptor,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId ?? existing.turnId,
|
||||
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||
acpSource: acpSource ?? existing.acpSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sealOpenMessages(d);
|
||||
pushItem(d, {
|
||||
id,
|
||||
type: "lifecycle",
|
||||
renderClass,
|
||||
title,
|
||||
text,
|
||||
timestamp,
|
||||
descriptor,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId,
|
||||
sessionId: ctx.sessionId,
|
||||
acpSource,
|
||||
});
|
||||
}
|
||||
|
||||
function upsertPlan(
|
||||
d: TranscriptDraft,
|
||||
id: string,
|
||||
title: string,
|
||||
text: string,
|
||||
timestamp: string,
|
||||
ctx: TranscriptItemContext,
|
||||
acpSource?: string,
|
||||
updateMarkerId?: string,
|
||||
) {
|
||||
const existing = d.itemsById.get(id);
|
||||
if (existing?.type === "plan") {
|
||||
const changed = existing.text !== text;
|
||||
replaceItem(d, id, {
|
||||
...existing,
|
||||
text,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId ?? existing.turnId,
|
||||
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||
acpSource: acpSource ?? existing.acpSource,
|
||||
});
|
||||
if (changed) {
|
||||
pushItem(d, {
|
||||
id: updateMarkerId ?? `${id}:update:${timestamp}`,
|
||||
type: "plan",
|
||||
renderClass: "plan",
|
||||
title: "Plan updated",
|
||||
text: summarizePlanUpdate(text),
|
||||
timestamp,
|
||||
isUpdate: true,
|
||||
targetId: id,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId,
|
||||
sessionId: ctx.sessionId,
|
||||
acpSource,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
sealOpenMessages(d);
|
||||
pushItem(d, {
|
||||
id,
|
||||
type: "plan",
|
||||
renderClass: "plan",
|
||||
title,
|
||||
text,
|
||||
timestamp,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId,
|
||||
sessionId: ctx.sessionId,
|
||||
acpSource,
|
||||
});
|
||||
}
|
||||
|
||||
function summarizePlanUpdate(text: string) {
|
||||
const taskMatches = [...text.matchAll(/\[[ xX]\]/g)];
|
||||
if (taskMatches.length > 0) {
|
||||
const completed = taskMatches.filter((match) =>
|
||||
match[0].toLowerCase().includes("x"),
|
||||
).length;
|
||||
return `${completed}/${taskMatches.length} complete`;
|
||||
}
|
||||
|
||||
const stepCount = text
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => /^\s*(?:[-*]|\d+[.)])\s+\S/.test(line)).length;
|
||||
return stepCount > 0 ? `${stepCount} step${stepCount === 1 ? "" : "s"}` : "";
|
||||
}
|
||||
|
||||
function upsertMetadata(
|
||||
@@ -174,15 +448,46 @@ function upsertMetadata(
|
||||
title: string,
|
||||
sections: PromptSection[],
|
||||
timestamp: string,
|
||||
channelId: string | null,
|
||||
ctx: TranscriptItemContext,
|
||||
acpSource?: string,
|
||||
) {
|
||||
const existing = d.itemsById.get(id);
|
||||
if (existing?.type === "metadata") {
|
||||
replaceItem(d, id, { ...existing, sections, channelId });
|
||||
replaceItem(d, id, {
|
||||
...existing,
|
||||
sections,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId ?? existing.turnId,
|
||||
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||
acpSource: acpSource ?? existing.acpSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sealOpenMessages(d);
|
||||
pushItem(d, { id, type: "metadata", title, sections, timestamp, channelId });
|
||||
pushItem(d, {
|
||||
id,
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title,
|
||||
sections,
|
||||
timestamp,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId,
|
||||
sessionId: ctx.sessionId,
|
||||
acpSource,
|
||||
});
|
||||
}
|
||||
|
||||
function isTerminalToolStatus(status: ToolStatus) {
|
||||
return status === "completed" || status === "failed";
|
||||
}
|
||||
|
||||
function mergeToolStatus(existing: ToolStatus, next: ToolStatus): ToolStatus {
|
||||
if (isTerminalToolStatus(existing) && !isTerminalToolStatus(next)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function upsertTool(
|
||||
@@ -196,7 +501,8 @@ function upsertTool(
|
||||
result: string,
|
||||
isError: boolean,
|
||||
timestamp: string,
|
||||
channelId: string | null,
|
||||
ctx: TranscriptItemContext,
|
||||
acpSource?: string,
|
||||
) {
|
||||
const existing = d.itemsById.get(id);
|
||||
const canonicalBuzzToolName =
|
||||
@@ -211,30 +517,57 @@ function upsertTool(
|
||||
} else if (!existing.buzzToolName && !isGenericToolTitle(toolName)) {
|
||||
updatedToolName = toolName;
|
||||
}
|
||||
replaceItem(d, id, {
|
||||
...existing,
|
||||
const mergedStatus = mergeToolStatus(existing.status, status);
|
||||
const updatedArgs = Object.keys(args).length > 0 ? args : existing.args;
|
||||
const updatedResult = result || existing.result;
|
||||
const updatedIsError = isError || existing.isError;
|
||||
const descriptor = classifyTool({
|
||||
title: updatedTitle,
|
||||
toolName: updatedToolName,
|
||||
buzzToolName: updatedBuzzToolName,
|
||||
status,
|
||||
args: Object.keys(args).length > 0 ? args : existing.args,
|
||||
result: result || existing.result,
|
||||
isError: isError || existing.isError,
|
||||
args: updatedArgs,
|
||||
result: updatedResult,
|
||||
isError: updatedIsError || mergedStatus === "failed",
|
||||
});
|
||||
replaceItem(d, id, {
|
||||
...existing,
|
||||
renderClass: descriptor.renderClass,
|
||||
descriptor,
|
||||
title: updatedTitle,
|
||||
toolName: updatedToolName,
|
||||
buzzToolName: updatedBuzzToolName,
|
||||
status: mergedStatus,
|
||||
args: updatedArgs,
|
||||
result: updatedResult,
|
||||
isError: updatedIsError,
|
||||
completedAt:
|
||||
(status === "completed" || status === "failed") &&
|
||||
existing.completedAt == null
|
||||
isTerminalToolStatus(mergedStatus) && existing.completedAt == null
|
||||
? timestamp
|
||||
: existing.completedAt,
|
||||
channelId,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId ?? existing.turnId,
|
||||
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||
acpSource: acpSource ?? existing.acpSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const resolvedToolName = canonicalBuzzToolName ?? toolName;
|
||||
const descriptor = classifyTool({
|
||||
title,
|
||||
toolName: resolvedToolName,
|
||||
buzzToolName: canonicalBuzzToolName,
|
||||
args,
|
||||
result,
|
||||
isError: isError || status === "failed",
|
||||
});
|
||||
sealOpenMessages(d);
|
||||
pushItem(d, {
|
||||
id,
|
||||
type: "tool",
|
||||
renderClass: descriptor.renderClass,
|
||||
descriptor,
|
||||
title,
|
||||
toolName: canonicalBuzzToolName ?? toolName,
|
||||
toolName: resolvedToolName,
|
||||
buzzToolName: canonicalBuzzToolName,
|
||||
status,
|
||||
args,
|
||||
@@ -242,8 +575,11 @@ function upsertTool(
|
||||
isError,
|
||||
timestamp,
|
||||
startedAt: timestamp,
|
||||
completedAt: null,
|
||||
channelId,
|
||||
completedAt: isTerminalToolStatus(status) ? timestamp : null,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId,
|
||||
sessionId: ctx.sessionId,
|
||||
acpSource,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,8 +595,34 @@ export function processTranscriptEvent(
|
||||
|
||||
const channelId = event.channelId ?? null;
|
||||
const ch = channelId ?? "global";
|
||||
const ctx: TranscriptItemContext = {
|
||||
channelId,
|
||||
turnId: event.turnId,
|
||||
sessionId: event.sessionId ?? d.latestSessionId,
|
||||
};
|
||||
|
||||
if (event.kind === "turn_started") {
|
||||
if (event.kind === "raw_json_rpc") {
|
||||
upsertMetadata(
|
||||
d,
|
||||
`raw-json-rpc:${ch}:${event.seq}`,
|
||||
"Raw ACP payload",
|
||||
[
|
||||
{
|
||||
title: rawPayloadTitle(event.payload),
|
||||
body: stringifyPayload(event.payload),
|
||||
},
|
||||
],
|
||||
event.timestamp,
|
||||
ctx,
|
||||
event.kind,
|
||||
);
|
||||
} else if (event.kind === "turn_started") {
|
||||
rememberTriggeringEventIds(
|
||||
d,
|
||||
ch,
|
||||
event.turnId ?? event.seq,
|
||||
extractTriggeringEventIds(event.payload),
|
||||
);
|
||||
upsertTextItem(
|
||||
d,
|
||||
`turn:${ch}:${event.turnId ?? event.seq}`,
|
||||
@@ -268,7 +630,8 @@ export function processTranscriptEvent(
|
||||
"Turn started",
|
||||
describeTurnStarted(event.payload),
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
event.kind,
|
||||
);
|
||||
} else if (event.kind === "session_resolved") {
|
||||
upsertTextItem(
|
||||
@@ -278,7 +641,8 @@ export function processTranscriptEvent(
|
||||
"Session ready",
|
||||
describeSessionResolved(event.payload),
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
event.kind,
|
||||
);
|
||||
} else if (event.kind === "acp_parse_error") {
|
||||
upsertTextItem(
|
||||
@@ -288,7 +652,8 @@ export function processTranscriptEvent(
|
||||
"Wire parse error",
|
||||
extractBlockText(event.payload),
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
event.kind,
|
||||
);
|
||||
} else if (event.kind === "turn_error" || event.kind === "agent_panic") {
|
||||
const payload = asRecord(event.payload);
|
||||
@@ -303,13 +668,27 @@ export function processTranscriptEvent(
|
||||
title,
|
||||
`${outcome}: ${error}`,
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
event.kind,
|
||||
);
|
||||
} else if (event.kind === "acp_read" || event.kind === "acp_write") {
|
||||
const payload = asRecord(event.payload);
|
||||
const method = asString(payload.method);
|
||||
|
||||
if (event.kind === "acp_write" && method === "session/prompt") {
|
||||
if (method === "session/request_permission") {
|
||||
const request = describePermissionRequest(payload);
|
||||
upsertLifecycleItem(
|
||||
d,
|
||||
`permission:${ch}:${event.turnId ?? event.seq}`,
|
||||
"permission",
|
||||
"Permission requested",
|
||||
request.text,
|
||||
event.timestamp,
|
||||
ctx,
|
||||
"permission_request",
|
||||
request.descriptor,
|
||||
);
|
||||
} else if (event.kind === "acp_write" && method === "session/prompt") {
|
||||
const promptText = extractPromptText(payload);
|
||||
if (promptText) {
|
||||
const parsedPrompt = parsePromptText(promptText);
|
||||
@@ -321,8 +700,11 @@ export function processTranscriptEvent(
|
||||
parsedPrompt.userTitle,
|
||||
parsedPrompt.userText,
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
parsedPrompt.userPubkey,
|
||||
"session/prompt:user",
|
||||
parsedPrompt.userEventId ??
|
||||
getSingleTriggeringEventId(d, ch, event.turnId ?? event.seq),
|
||||
);
|
||||
}
|
||||
if (parsedPrompt.sections.length > 0) {
|
||||
@@ -332,7 +714,8 @@ export function processTranscriptEvent(
|
||||
"Prompt context",
|
||||
parsedPrompt.sections,
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
"session/prompt:context",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -353,7 +736,7 @@ export function processTranscriptEvent(
|
||||
"System prompt",
|
||||
sections,
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -372,8 +755,10 @@ export function processTranscriptEvent(
|
||||
parsedPrompt.userTitle,
|
||||
parsedPrompt.userText,
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
parsedPrompt.userPubkey,
|
||||
undefined,
|
||||
parsedPrompt.userEventId,
|
||||
);
|
||||
}
|
||||
if (parsedPrompt.sections.length > 0) {
|
||||
@@ -383,7 +768,7 @@ export function processTranscriptEvent(
|
||||
"Prompt context",
|
||||
parsedPrompt.sections,
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -402,13 +787,17 @@ export function processTranscriptEvent(
|
||||
"Assistant",
|
||||
extractContentText(update.content),
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
null,
|
||||
updateType,
|
||||
);
|
||||
} else if (updateType === "user_message_chunk") {
|
||||
// Suppress user_message_chunk echo when a steer already rendered
|
||||
// the user message for this turn (Goose echoes steered content back).
|
||||
const steerKey = `steer:${ch}:${event.turnId ?? event.seq}`;
|
||||
const authorPubkey = asString(update.authorPubkey);
|
||||
if (!d.itemsById.has(steerKey)) {
|
||||
const channelMessageId = maybeNostrEventId(messageId);
|
||||
upsertMessage(
|
||||
d,
|
||||
`user:${ch}:${messageId ?? turnKey}`,
|
||||
@@ -416,7 +805,10 @@ export function processTranscriptEvent(
|
||||
"User",
|
||||
extractContentText(update.content),
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
authorPubkey,
|
||||
updateType,
|
||||
channelMessageId,
|
||||
);
|
||||
}
|
||||
} else if (updateType === "agent_thought_chunk") {
|
||||
@@ -427,7 +819,8 @@ export function processTranscriptEvent(
|
||||
"Thinking",
|
||||
extractContentText(update.content),
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
updateType,
|
||||
);
|
||||
} else if (updateType === "tool_call") {
|
||||
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
|
||||
@@ -443,7 +836,8 @@ export function processTranscriptEvent(
|
||||
extractToolResult(update),
|
||||
false,
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
updateType,
|
||||
);
|
||||
} else if (updateType === "tool_call_update") {
|
||||
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
|
||||
@@ -462,17 +856,53 @@ export function processTranscriptEvent(
|
||||
extractToolResult(update),
|
||||
status === "failed",
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
updateType,
|
||||
);
|
||||
} else if (updateType === "plan") {
|
||||
upsertTextItem(
|
||||
upsertPlan(
|
||||
d,
|
||||
`plan:${ch}:${turnKey}`,
|
||||
"thought",
|
||||
"Plan",
|
||||
extractContentText(update.content) || JSON.stringify(update, null, 2),
|
||||
event.timestamp,
|
||||
channelId,
|
||||
ctx,
|
||||
updateType,
|
||||
`plan-update:${ch}:${turnKey}:${event.seq}`,
|
||||
);
|
||||
} else {
|
||||
// Free-form observer status records are not part of the ACP session/update
|
||||
// union. Surface only explicit title/text payloads; leave all other
|
||||
// unknown frames out of the feed instead of guessing at semantics.
|
||||
const status = describeFreeformStatus(payload);
|
||||
if (status) {
|
||||
upsertLifecycleItem(
|
||||
d,
|
||||
`status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`,
|
||||
"status",
|
||||
status.title,
|
||||
status.text,
|
||||
event.timestamp,
|
||||
ctx,
|
||||
status.statusType,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Free-form observer status records are not part of the ACP JSON-RPC
|
||||
// method set. Surface only explicit title/text payloads; leave all other
|
||||
// unknown frames out of the feed instead of guessing at semantics.
|
||||
const status = describeFreeformStatus(payload);
|
||||
if (status) {
|
||||
upsertLifecycleItem(
|
||||
d,
|
||||
`status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`,
|
||||
"status",
|
||||
status.title,
|
||||
status.text,
|
||||
event.timestamp,
|
||||
ctx,
|
||||
status.statusType,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -487,6 +917,7 @@ export function processTranscriptEvent(
|
||||
itemsById: d.itemsById,
|
||||
activeMessageKey: d.activeMessageKey,
|
||||
sealedKeys: d.sealedKeys,
|
||||
triggeringEventIdsByTurn: d.triggeringEventIdsByTurn,
|
||||
continuationSeq: d.continuationSeq,
|
||||
latestSessionId: d.latestSessionId,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTranscriptDisplayBlocks,
|
||||
flattenDisplayBlocks,
|
||||
formatTurnSetupLabel,
|
||||
} from "./agentSessionTranscriptGrouping.ts";
|
||||
|
||||
const baseTimestamp = "2026-06-14T22:20:23.000Z";
|
||||
|
||||
function lifecycle(id, title, acpSource, turnId, text = "") {
|
||||
return {
|
||||
id,
|
||||
type: "lifecycle",
|
||||
title,
|
||||
text,
|
||||
timestamp: baseTimestamp,
|
||||
acpSource,
|
||||
turnId,
|
||||
sessionId: "sess-1",
|
||||
channelId: "channel-1",
|
||||
};
|
||||
}
|
||||
|
||||
function userPrompt(id, text, turnId) {
|
||||
return {
|
||||
id,
|
||||
type: "message",
|
||||
role: "user",
|
||||
title: "Buzz event",
|
||||
text,
|
||||
timestamp: baseTimestamp,
|
||||
acpSource: "session/prompt:user",
|
||||
turnId,
|
||||
sessionId: "sess-1",
|
||||
channelId: "channel-1",
|
||||
};
|
||||
}
|
||||
|
||||
function promptContext(id, turnId) {
|
||||
return {
|
||||
id,
|
||||
type: "metadata",
|
||||
title: "Prompt context",
|
||||
sections: [{ title: "Channel", body: "general" }],
|
||||
timestamp: baseTimestamp,
|
||||
acpSource: "session/prompt:context",
|
||||
turnId,
|
||||
sessionId: "sess-1",
|
||||
channelId: "channel-1",
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id, text, turnId) {
|
||||
return {
|
||||
id,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
title: "Assistant",
|
||||
text,
|
||||
timestamp: "2026-06-14T22:20:47.000Z",
|
||||
acpSource: "agent_message_chunk",
|
||||
turnId,
|
||||
sessionId: "sess-1",
|
||||
channelId: "channel-1",
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id, turnId) {
|
||||
return {
|
||||
id,
|
||||
type: "tool",
|
||||
title: "Shell",
|
||||
toolName: "buzz-dev-mcp__shell",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: {},
|
||||
result: "ok",
|
||||
isError: false,
|
||||
timestamp: "2026-06-14T22:20:47.000Z",
|
||||
startedAt: "2026-06-14T22:20:47.000Z",
|
||||
completedAt: "2026-06-14T22:20:47.400Z",
|
||||
acpSource: "tool_call_update",
|
||||
turnId,
|
||||
sessionId: "sess-1",
|
||||
channelId: "channel-1",
|
||||
};
|
||||
}
|
||||
|
||||
test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context together", () => {
|
||||
const rawItems = [
|
||||
lifecycle(
|
||||
"turn",
|
||||
"Turn started",
|
||||
"turn_started",
|
||||
"turn-1",
|
||||
"Triggered by 1 event.",
|
||||
),
|
||||
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
|
||||
userPrompt("prompt", "@Ned deliberate, wider pass", "turn-1"),
|
||||
promptContext("context", "turn-1"),
|
||||
assistantMessage("assistant", "Thinking out loud.", "turn-1"),
|
||||
toolCall("tool", "turn-1"),
|
||||
];
|
||||
|
||||
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
||||
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
|
||||
|
||||
assert.deepEqual(displayOrder, [
|
||||
"prompt",
|
||||
"turn",
|
||||
"session",
|
||||
"context",
|
||||
"assistant",
|
||||
"tool",
|
||||
]);
|
||||
|
||||
const turnBlock = blocks[0];
|
||||
assert.equal(turnBlock?.kind, "turn");
|
||||
assert.equal(turnBlock.segments[0]?.kind, "prompt");
|
||||
const promptSegment = turnBlock.segments[0];
|
||||
assert.equal(promptSegment.user.id, "prompt");
|
||||
assert.equal(promptSegment.context?.id, "context");
|
||||
assert.equal(promptSegment.setup.length, 2);
|
||||
assert.equal(turnBlock.segments[1]?.kind, "item");
|
||||
assert.equal(turnBlock.segments[2]?.kind, "item");
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => {
|
||||
const rawItems = [
|
||||
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
|
||||
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
|
||||
userPrompt("prompt", "hello", "turn-1"),
|
||||
];
|
||||
|
||||
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0]?.kind, "turn");
|
||||
|
||||
const turnBlock = blocks[0];
|
||||
assert.equal(turnBlock.segments.length, 1);
|
||||
assert.equal(turnBlock.segments[0]?.kind, "prompt");
|
||||
assert.equal(
|
||||
formatTurnSetupLabel(turnBlock.segments[0].setup),
|
||||
"Turn started · Session ready",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks hides setup and context when prompt is missing", () => {
|
||||
const rawItems = [
|
||||
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
|
||||
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
|
||||
promptContext("context", "turn-1"),
|
||||
toolCall("tool", "turn-1"),
|
||||
];
|
||||
|
||||
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
||||
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
|
||||
|
||||
assert.deepEqual(displayOrder, ["tool"]);
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks drops setup-and-context-only turns", () => {
|
||||
const rawItems = [
|
||||
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
|
||||
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
|
||||
promptContext("context", "turn-1"),
|
||||
];
|
||||
|
||||
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
||||
|
||||
assert.deepEqual(blocks, []);
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks leaves error lifecycle prominent outside prompt bundle", () => {
|
||||
const rawItems = [
|
||||
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
|
||||
userPrompt("prompt", "hello", "turn-1"),
|
||||
lifecycle(
|
||||
"error",
|
||||
"Turn error",
|
||||
"turn_error",
|
||||
"turn-1",
|
||||
"timeout: agent hung",
|
||||
),
|
||||
];
|
||||
|
||||
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
||||
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
|
||||
|
||||
assert.deepEqual(displayOrder, ["prompt", "turn", "error"]);
|
||||
assert.equal(blocks[0]?.segments[0]?.kind, "prompt");
|
||||
assert.equal(blocks[0]?.segments[1]?.kind, "item");
|
||||
assert.equal(blocks[0]?.segments[1]?.item.id, "error");
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks passes through items without turnId", () => {
|
||||
const orphan = {
|
||||
id: "orphan",
|
||||
type: "lifecycle",
|
||||
title: "Wire parse error",
|
||||
text: "bad json",
|
||||
timestamp: baseTimestamp,
|
||||
acpSource: "acp_parse_error",
|
||||
channelId: "channel-1",
|
||||
};
|
||||
|
||||
const blocks = buildTranscriptDisplayBlocks([orphan]);
|
||||
assert.equal(blocks.length, 1);
|
||||
assert.equal(blocks[0]?.kind, "single");
|
||||
assert.equal(blocks[0]?.item.id, "orphan");
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks groups same-kind tool runs within a turn", () => {
|
||||
const items = [1, 2, 3].map((index) => ({
|
||||
id: `tool:${index}`,
|
||||
type: "tool",
|
||||
renderClass: "generic",
|
||||
descriptor: {
|
||||
renderClass: "generic",
|
||||
label: "Read file",
|
||||
preview: `file-${index}.ts`,
|
||||
groupKey: "read_file",
|
||||
},
|
||||
title: "read_file",
|
||||
toolName: "read_file",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: { path: `file-${index}.ts` },
|
||||
result: "",
|
||||
isError: false,
|
||||
timestamp: "2026-06-18T00:00:00Z",
|
||||
startedAt: "2026-06-18T00:00:00Z",
|
||||
completedAt: "2026-06-18T00:00:01Z",
|
||||
turnId: "turn-1",
|
||||
sessionId: "sess-1",
|
||||
channelId: "chan-1",
|
||||
}));
|
||||
|
||||
const [block] = buildTranscriptDisplayBlocks(items);
|
||||
|
||||
assert.equal(block.kind, "turn");
|
||||
assert.equal(block.segments.length, 1);
|
||||
assert.equal(block.segments[0].kind, "summary");
|
||||
assert.equal(block.segments[0].summary.label, "Read 3 files");
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks groups consecutive file edit tool runs", () => {
|
||||
const items = [1, 2].map((index) => ({
|
||||
id: `edit:${index}`,
|
||||
type: "tool",
|
||||
renderClass: "file-edit",
|
||||
descriptor: {
|
||||
renderClass: "file-edit",
|
||||
label: "Edited file",
|
||||
preview: `src/file-${index}.ts`,
|
||||
groupKey: "file-edit:str_replace",
|
||||
},
|
||||
title: "str_replace",
|
||||
toolName: "str_replace",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: { path: `src/file-${index}.ts` },
|
||||
result: "",
|
||||
isError: false,
|
||||
timestamp: "2026-06-18T00:00:00Z",
|
||||
startedAt: "2026-06-18T00:00:00Z",
|
||||
completedAt: "2026-06-18T00:00:01Z",
|
||||
turnId: "turn-1",
|
||||
sessionId: "sess-1",
|
||||
channelId: "chan-1",
|
||||
}));
|
||||
|
||||
const [block] = buildTranscriptDisplayBlocks(items);
|
||||
|
||||
assert.equal(block.kind, "turn");
|
||||
assert.equal(block.segments.length, 1);
|
||||
assert.equal(block.segments[0].kind, "summary");
|
||||
assert.equal(block.segments[0].summary.label, "Edited 2 files");
|
||||
assert.equal(block.segments[0].summary.renderClass, "file-edit");
|
||||
assert.deepEqual(
|
||||
block.segments[0].summary.items.map((item) => item.id),
|
||||
["edit:1", "edit:2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded", () => {
|
||||
const mkTool = (id, label, renderClass = "generic", groupKey = label) => ({
|
||||
id,
|
||||
type: "tool",
|
||||
renderClass,
|
||||
descriptor: {
|
||||
renderClass,
|
||||
label,
|
||||
preview: id,
|
||||
source: "harness",
|
||||
groupKey,
|
||||
},
|
||||
title: label,
|
||||
toolName: label,
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: {},
|
||||
result: "",
|
||||
isError: false,
|
||||
timestamp: "2026-06-18T00:00:00Z",
|
||||
startedAt: "2026-06-18T00:00:00Z",
|
||||
completedAt: "2026-06-18T00:00:01Z",
|
||||
turnId: "turn-1",
|
||||
sessionId: "sess-1",
|
||||
channelId: "chan-1",
|
||||
});
|
||||
|
||||
const [block] = buildTranscriptDisplayBlocks([
|
||||
mkTool("read-1", "Read file", "generic", "read_file"),
|
||||
mkTool("shell-1", "Ran command", "shell", "shell:command"),
|
||||
mkTool("read-2", "Read file", "generic", "read_file"),
|
||||
mkTool("read-3", "Read file", "generic", "read_file"),
|
||||
]);
|
||||
|
||||
assert.equal(block.kind, "turn");
|
||||
assert.deepEqual(
|
||||
block.segments.map((segment) => segment.kind),
|
||||
["item", "item", "item", "item"],
|
||||
);
|
||||
});
|
||||
291
desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
Normal file
291
desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
import { classifyToolItem } from "./agentSessionToolClassifier";
|
||||
|
||||
export type TranscriptTurnSegment =
|
||||
| { kind: "item"; item: TranscriptItem }
|
||||
| { kind: "summary"; summary: TranscriptSameKindSummary }
|
||||
| { kind: "setup"; items: Extract<TranscriptItem, { type: "lifecycle" }>[] }
|
||||
| {
|
||||
kind: "prompt";
|
||||
user: Extract<TranscriptItem, { type: "message" }>;
|
||||
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
};
|
||||
|
||||
export type TranscriptDisplayBlock =
|
||||
| { kind: "single"; item: TranscriptItem }
|
||||
| { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] };
|
||||
|
||||
export type TranscriptSameKindSummary = {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
items: TranscriptItem[];
|
||||
renderClass: TranscriptItem["renderClass"] | null;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
function isUserPrompt(
|
||||
item: TranscriptItem,
|
||||
): item is Extract<TranscriptItem, { type: "message" }> {
|
||||
return (
|
||||
item.type === "message" &&
|
||||
item.role === "user" &&
|
||||
item.acpSource === "session/prompt:user"
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptContext(
|
||||
item: TranscriptItem,
|
||||
): item is Extract<TranscriptItem, { type: "metadata" }> {
|
||||
return (
|
||||
item.type === "metadata" && item.acpSource === "session/prompt:context"
|
||||
);
|
||||
}
|
||||
|
||||
function isSetupLifecycle(
|
||||
item: TranscriptItem,
|
||||
): item is Extract<TranscriptItem, { type: "lifecycle" }> {
|
||||
return (
|
||||
item.type === "lifecycle" &&
|
||||
(item.acpSource === "turn_started" || item.acpSource === "session_resolved")
|
||||
);
|
||||
}
|
||||
|
||||
type TurnBucket = {
|
||||
turnId: string;
|
||||
items: TranscriptItem[];
|
||||
};
|
||||
|
||||
function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
|
||||
const userPrompt = items.find(isUserPrompt) ?? null;
|
||||
const setupLifecycle = items.filter(isSetupLifecycle);
|
||||
const promptContext = items.find(isPromptContext) ?? null;
|
||||
const consumed = new Set<TranscriptItem>();
|
||||
|
||||
if (userPrompt) consumed.add(userPrompt);
|
||||
for (const item of setupLifecycle) consumed.add(item);
|
||||
if (promptContext) consumed.add(promptContext);
|
||||
|
||||
const activity = items.filter((item) => !consumed.has(item));
|
||||
|
||||
if (!userPrompt) {
|
||||
return groupSameKindSegments(
|
||||
activity.map((item) => ({ kind: "item", item })),
|
||||
);
|
||||
}
|
||||
|
||||
const segments: TranscriptTurnSegment[] = [
|
||||
{
|
||||
kind: "prompt",
|
||||
user: userPrompt,
|
||||
context: promptContext,
|
||||
setup: setupLifecycle,
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of activity) {
|
||||
segments.push({ kind: "item", item });
|
||||
}
|
||||
|
||||
return groupSameKindSegments(segments);
|
||||
}
|
||||
|
||||
function groupSameKindSegments(
|
||||
segments: TranscriptTurnSegment[],
|
||||
): TranscriptTurnSegment[] {
|
||||
const grouped: TranscriptTurnSegment[] = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
if (segment.kind !== "item") {
|
||||
grouped.push(segment);
|
||||
continue;
|
||||
}
|
||||
const key = sameKindKey(segment.item);
|
||||
if (!key) {
|
||||
grouped.push(segment);
|
||||
continue;
|
||||
}
|
||||
const run = [segment.item];
|
||||
let j = i + 1;
|
||||
while (j < segments.length) {
|
||||
const next = segments[j];
|
||||
if (next.kind !== "item" || sameKindKey(next.item) !== key) break;
|
||||
run.push(next.item);
|
||||
j += 1;
|
||||
}
|
||||
if (run.length >= minimumSummaryRunLength(run[0])) {
|
||||
grouped.push({
|
||||
kind: "summary",
|
||||
summary: {
|
||||
id: `summary:${key}:${run[0].id}`,
|
||||
label: sameKindLabel(run[0], run.length),
|
||||
count: run.length,
|
||||
items: run,
|
||||
renderClass: getRenderClass(run[0]),
|
||||
timestamp: run[0].timestamp,
|
||||
},
|
||||
});
|
||||
i = j - 1;
|
||||
} else {
|
||||
grouped.push(...run.map((item) => ({ kind: "item" as const, item })));
|
||||
i = j - 1;
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function sameKindKey(item: TranscriptItem): string | null {
|
||||
if (item.type !== "tool") return null;
|
||||
const renderClass = getRenderClass(item);
|
||||
if (renderClass === "message") {
|
||||
return null;
|
||||
}
|
||||
const descriptor = item.descriptor ?? classifyToolItem(item);
|
||||
return descriptor.groupKey ?? renderClass;
|
||||
}
|
||||
|
||||
function sameKindLabel(item: TranscriptItem, count: number): string {
|
||||
if (item.type !== "tool") return `${count} items`;
|
||||
const descriptor = item.descriptor ?? classifyToolItem(item);
|
||||
const renderClass = getRenderClass(item);
|
||||
const label = descriptor.label;
|
||||
if (renderClass === "file-edit") {
|
||||
return `Edited ${count} file${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (label === "Read file") return `Read ${count} files`;
|
||||
if (label === "Ran command") return `Ran ${count} commands`;
|
||||
if (renderClass === "relay-op") return `Ran ${count} Buzz relay ops`;
|
||||
return `${label} ×${count}`;
|
||||
}
|
||||
|
||||
function minimumSummaryRunLength(item: TranscriptItem): number {
|
||||
return getRenderClass(item) === "file-edit" ? 2 : 3;
|
||||
}
|
||||
|
||||
function getRenderClass(item: TranscriptItem) {
|
||||
if (item.type !== "tool") return item.renderClass;
|
||||
const descriptor = item.descriptor ?? classifyToolItem(item);
|
||||
return item.renderClass ?? descriptor.renderClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build presentation-only display blocks from normalized transcript items.
|
||||
* Raw observer order is preserved in the source items; this only reorders
|
||||
* within a turn for user-facing narrative flow.
|
||||
*/
|
||||
export function buildTranscriptDisplayBlocks(
|
||||
items: TranscriptItem[],
|
||||
): TranscriptDisplayBlock[] {
|
||||
const blocks: TranscriptDisplayBlock[] = [];
|
||||
const turnBuckets = new Map<string, TurnBucket>();
|
||||
// Callers pass a channel-scoped item stream; revisit this bare turnId bucket
|
||||
// if grouping ever receives multi-channel transcript items.
|
||||
const displayOrder: Array<
|
||||
{ kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string }
|
||||
> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const turnId = item.turnId;
|
||||
if (!turnId) {
|
||||
displayOrder.push({ kind: "single", item });
|
||||
continue;
|
||||
}
|
||||
|
||||
let bucket = turnBuckets.get(turnId);
|
||||
if (!bucket) {
|
||||
bucket = { turnId, items: [] };
|
||||
turnBuckets.set(turnId, bucket);
|
||||
displayOrder.push({ kind: "turn", turnId });
|
||||
}
|
||||
bucket.items.push(item);
|
||||
}
|
||||
|
||||
for (const entry of displayOrder) {
|
||||
if (entry.kind === "single") {
|
||||
blocks.push({ kind: "single", item: entry.item });
|
||||
continue;
|
||||
}
|
||||
|
||||
const bucket = turnBuckets.get(entry.turnId);
|
||||
if (!bucket || bucket.items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const segments = classifyTurnItems(bucket.items);
|
||||
if (segments.length > 0) {
|
||||
blocks.push({
|
||||
kind: "turn",
|
||||
turnId: entry.turnId,
|
||||
segments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Flatten display blocks back to items for testing display order. */
|
||||
export function flattenDisplayBlocks(
|
||||
blocks: TranscriptDisplayBlock[],
|
||||
): TranscriptItem[] {
|
||||
const result: TranscriptItem[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.kind === "single") {
|
||||
result.push(block.item);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const segment of block.segments) {
|
||||
if (segment.kind === "item") {
|
||||
result.push(segment.item);
|
||||
} else if (segment.kind === "prompt") {
|
||||
result.push(segment.user);
|
||||
result.push(...segment.setup);
|
||||
if (segment.context) {
|
||||
result.push(segment.context);
|
||||
}
|
||||
} else if (segment.kind === "summary") {
|
||||
result.push(...segment.summary.items);
|
||||
} else {
|
||||
result.push(...segment.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Human-readable labels for a collapsed turn setup row. */
|
||||
export function formatTurnSetupLabel(
|
||||
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
|
||||
): string {
|
||||
const labels = items.map((item) => item.title);
|
||||
return labels.join(" · ");
|
||||
}
|
||||
|
||||
/** Earliest timestamp among setup lifecycle items. */
|
||||
export function turnSetupTimestamp(
|
||||
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
|
||||
): string | null {
|
||||
if (items.length === 0) return null;
|
||||
return items.reduce(
|
||||
(earliest, item) =>
|
||||
Date.parse(item.timestamp) < Date.parse(earliest)
|
||||
? item.timestamp
|
||||
: earliest,
|
||||
items[0].timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
/** Optional detail text from setup lifecycle items (e.g. trigger count). */
|
||||
export function turnSetupDetail(
|
||||
items: Extract<TranscriptItem, { type: "lifecycle" }>[],
|
||||
): string | null {
|
||||
const details = items
|
||||
.map((item) => item.text.trim())
|
||||
.filter((text) => text.length > 0);
|
||||
if (details.length === 0) return null;
|
||||
return details.join(" ");
|
||||
}
|
||||
@@ -20,6 +20,7 @@ test("parsePromptText returns the empty/Prompt fallback for whitespace-only inpu
|
||||
userText: "",
|
||||
userTitle: "Prompt",
|
||||
userPubkey: null,
|
||||
userEventId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,14 +37,16 @@ test("parsePromptText wraps header-less free text in a single Prompt section", (
|
||||
assert.equal(result.userText, "");
|
||||
assert.equal(result.userTitle, "Buzz event");
|
||||
assert.equal(result.userPubkey, null);
|
||||
assert.equal(result.userEventId, null);
|
||||
});
|
||||
|
||||
test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () => {
|
||||
test("parsePromptText extracts event id, content, hex pubkey, and a title-cased kind", () => {
|
||||
const text = [
|
||||
"[System]",
|
||||
"system preamble here",
|
||||
"",
|
||||
"[Buzz event: @mention]",
|
||||
`Event ID: ${HEX_UPPER}`,
|
||||
"Channel: demo",
|
||||
`From: Wes (hex: ${HEX})`,
|
||||
"Content: hello @Brain please look",
|
||||
@@ -53,6 +56,7 @@ test("parsePromptText extracts content, hex pubkey, and a title-cased kind", ()
|
||||
|
||||
assert.equal(result.userText, "hello @Brain please look");
|
||||
assert.equal(result.userPubkey, HEX);
|
||||
assert.equal(result.userEventId, HEX);
|
||||
// titleCase capitalizes after word boundaries but leaves the leading "@"
|
||||
// (a non-word char) in place: "@mention" -> "@Mention".
|
||||
assert.equal(result.userTitle, "@Mention");
|
||||
@@ -63,6 +67,38 @@ test("parsePromptText extracts content, hex pubkey, and a title-cased kind", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("parsePromptText preserves multiline event content in the user bubble text", () => {
|
||||
const text = [
|
||||
"[Buzz event: @mention]",
|
||||
"Event ID: event-1",
|
||||
"Channel: agents",
|
||||
`From: tho (hex: ${HEX})`,
|
||||
"Time: 2026-06-15T17:15:00Z",
|
||||
"Content: @Ned",
|
||||
"",
|
||||
"- remove that stray cherry pick if it's not adding value here",
|
||||
"- help me understand what that e2eBridge change does",
|
||||
"- we'd want the e2e seed path as a separate pull request",
|
||||
'Tags: [["h","agents"]]',
|
||||
"Parsed: mentions=[Ned]",
|
||||
].join("\n");
|
||||
|
||||
const result = parsePromptText(text);
|
||||
|
||||
assert.equal(
|
||||
result.userText,
|
||||
[
|
||||
"@Ned",
|
||||
"",
|
||||
"- remove that stray cherry pick if it's not adding value here",
|
||||
"- help me understand what that e2eBridge change does",
|
||||
"- we'd want the e2e seed path as a separate pull request",
|
||||
].join("\n"),
|
||||
);
|
||||
assert.equal(result.userPubkey, HEX);
|
||||
assert.equal(result.userEventId, null);
|
||||
});
|
||||
|
||||
test("parsePromptText lowercases the extracted hex pubkey", () => {
|
||||
const text = [
|
||||
"[Buzz event: dm]",
|
||||
|
||||
@@ -18,6 +18,7 @@ export function parsePromptText(text: string): {
|
||||
userText: string;
|
||||
userTitle: string;
|
||||
userPubkey: string | null;
|
||||
userEventId: string | null;
|
||||
} {
|
||||
const sections = parsePromptSections(text).filter(
|
||||
(s) => s.body.trim().length > 0,
|
||||
@@ -28,12 +29,13 @@ export function parsePromptText(text: string): {
|
||||
userText: text.trim(),
|
||||
userTitle: "Prompt",
|
||||
userPubkey: null,
|
||||
userEventId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const eventSection = sections.find((section) => {
|
||||
const title = section.title.toLowerCase();
|
||||
return title.startsWith("buzz event") || title.startsWith("buzz event");
|
||||
return title.startsWith("buzz event");
|
||||
});
|
||||
const eventContent = eventSection
|
||||
? extractEventContent(eventSection.body)
|
||||
@@ -41,6 +43,7 @@ export function parsePromptText(text: string): {
|
||||
const eventAuthorPubkey = eventSection
|
||||
? extractEventAuthorPubkey(eventSection.body)
|
||||
: null;
|
||||
const eventId = eventSection ? extractEventId(eventSection.body) : null;
|
||||
const eventKind = eventSection?.title.split(":").slice(1).join(":").trim();
|
||||
|
||||
return {
|
||||
@@ -48,6 +51,7 @@ export function parsePromptText(text: string): {
|
||||
userText: eventContent,
|
||||
userTitle: eventKind ? titleCase(eventKind) : "Buzz event",
|
||||
userPubkey: eventAuthorPubkey,
|
||||
userEventId: eventId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,9 +131,39 @@ function parsePromptSections(text: string): PromptSection[] {
|
||||
return sections;
|
||||
}
|
||||
|
||||
const EVENT_CONTENT_BOUNDARY_RE =
|
||||
/^(?:Event ID|Channel|Kind|From|Time|Tags|Parsed):\s*/;
|
||||
const EVENT_BLOCK_BOUNDARY_RE = /^--- Event \d+\b/;
|
||||
|
||||
function extractEventContent(body: string): string {
|
||||
const contentMatch = body.match(/^Content:\s*(.*)$/m);
|
||||
return contentMatch?.[1]?.trim() ?? "";
|
||||
const lines = body.split(/\r?\n/);
|
||||
const chunks: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^Content:\s?(.*)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentLines = [match[1] ?? ""];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const line = lines[j];
|
||||
if (
|
||||
EVENT_CONTENT_BOUNDARY_RE.test(line) ||
|
||||
EVENT_BLOCK_BOUNDARY_RE.test(line)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
contentLines.push(line);
|
||||
}
|
||||
|
||||
const content = contentLines.join("\n").trim();
|
||||
if (content) {
|
||||
chunks.push(content);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks.join("\n\n");
|
||||
}
|
||||
|
||||
function extractEventAuthorPubkey(body: string): string | null {
|
||||
@@ -137,6 +171,11 @@ function extractEventAuthorPubkey(body: string): string | null {
|
||||
return fromMatch?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function extractEventId(body: string): string | null {
|
||||
const eventIdMatch = body.match(/^Event ID:\s*([0-9a-fA-F]{64})\b/m);
|
||||
return eventIdMatch?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
export function extractContentText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value.map(extractBlockText).join("\n");
|
||||
@@ -242,13 +281,17 @@ export function extractToolResult(update: Record<string, unknown>): string {
|
||||
return extractBlockText(update.rawOutput);
|
||||
}
|
||||
|
||||
export function describeTurnStarted(payload: unknown): string {
|
||||
export function extractTriggeringEventIds(payload: unknown): string[] {
|
||||
const record = asRecord(payload);
|
||||
const ids = Array.isArray(record.triggeringEventIds)
|
||||
return Array.isArray(record.triggeringEventIds)
|
||||
? record.triggeringEventIds.filter(
|
||||
(id): id is string => typeof id === "string",
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function describeTurnStarted(payload: unknown): string {
|
||||
const ids = extractTriggeringEventIds(payload);
|
||||
return ids.length > 0
|
||||
? `Triggered by ${ids.length === 1 ? "1 event" : `${ids.length} events`}.`
|
||||
: "";
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
getActivityHeadline,
|
||||
isMeaningfulItem,
|
||||
} from "./agentSessionTranscriptPresentation.ts";
|
||||
|
||||
const baseTimestamp = "2026-06-14T19:00:00.000Z";
|
||||
|
||||
function makeTool(overrides = {}) {
|
||||
return {
|
||||
id: "tool:1",
|
||||
type: "tool",
|
||||
title: "Send Message",
|
||||
toolName: "send_message",
|
||||
buzzToolName: "send_message",
|
||||
status: "executing",
|
||||
args: { channel_id: "abc" },
|
||||
result: "",
|
||||
isError: false,
|
||||
timestamp: baseTimestamp,
|
||||
startedAt: baseTimestamp,
|
||||
completedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(overrides = {}) {
|
||||
return {
|
||||
id: "msg:1",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
title: "Assistant",
|
||||
text: "Looking into that now.",
|
||||
timestamp: baseTimestamp,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("getActivityHeadline formats tool titles and assistant text", () => {
|
||||
assert.equal(getActivityHeadline(makeTool()), "Send Message · abc");
|
||||
assert.equal(
|
||||
getActivityHeadline(makeMessage({ text: "First line\nSecond line" })),
|
||||
"First line",
|
||||
);
|
||||
assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding");
|
||||
});
|
||||
|
||||
test("isMeaningfulItem ignores lifecycle noise and metadata", () => {
|
||||
assert.equal(
|
||||
isMeaningfulItem({
|
||||
id: "life:1",
|
||||
type: "lifecycle",
|
||||
title: "Turn started",
|
||||
text: "",
|
||||
timestamp: baseTimestamp,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulItem({
|
||||
id: "meta:1",
|
||||
type: "metadata",
|
||||
title: "Prompt context",
|
||||
sections: [],
|
||||
timestamp: baseTimestamp,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isMeaningfulItem({
|
||||
id: "life:2",
|
||||
type: "lifecycle",
|
||||
title: "Turn error",
|
||||
text: "boom",
|
||||
timestamp: baseTimestamp,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("getActivityHeadline uses semantic tool descriptors", () => {
|
||||
assert.equal(
|
||||
getActivityHeadline(
|
||||
makeTool({
|
||||
title: "Shell",
|
||||
toolName: "dev__shell",
|
||||
buzzToolName: null,
|
||||
args: { command: "buzz messages send --content hi" },
|
||||
descriptor: {
|
||||
renderClass: "message",
|
||||
label: "Send Message",
|
||||
preview: "hi",
|
||||
source: "shell",
|
||||
groupKey: "buzz-cli:messages.send",
|
||||
},
|
||||
}),
|
||||
),
|
||||
"Send Message · hi",
|
||||
);
|
||||
});
|
||||
|
||||
test("isMeaningfulItem ignores suppressed tools", () => {
|
||||
assert.equal(
|
||||
isMeaningfulItem(
|
||||
makeTool({
|
||||
renderClass: "suppressed",
|
||||
descriptor: {
|
||||
renderClass: "suppressed",
|
||||
label: "Checked todos",
|
||||
preview: null,
|
||||
source: "harness",
|
||||
groupKey: "suppressed:stop-hook",
|
||||
},
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
import { buildCompactToolSummary } from "./agentSessionToolSummary";
|
||||
|
||||
const LIFECYCLE_NOISE = new Set([
|
||||
"turn started",
|
||||
"session ready",
|
||||
"wire parse error",
|
||||
]);
|
||||
|
||||
/** Human-readable headline for a single transcript item. */
|
||||
export function getActivityHeadline(item: TranscriptItem): string | null {
|
||||
if (item.type === "tool") {
|
||||
const summary = buildCompactToolSummary(item);
|
||||
return [summary.label, summary.preview].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
if (item.type === "message") {
|
||||
if (item.role === "assistant") {
|
||||
const trimmed = item.text.trim();
|
||||
if (trimmed.length > 0) {
|
||||
const firstLine = trimmed.split("\n")[0]?.trim() ?? "";
|
||||
if (firstLine.length > 0) {
|
||||
return firstLine.length > 72
|
||||
? `${firstLine.slice(0, 69)}…`
|
||||
: firstLine;
|
||||
}
|
||||
}
|
||||
return "Responding";
|
||||
}
|
||||
return item.title || "User prompt";
|
||||
}
|
||||
|
||||
if (item.type === "thought") {
|
||||
return item.title === "Plan" ? "Planning" : item.title;
|
||||
}
|
||||
|
||||
if (item.type === "metadata") {
|
||||
return item.title;
|
||||
}
|
||||
|
||||
return item.title;
|
||||
}
|
||||
|
||||
function isLifecycleNoise(
|
||||
item: Extract<TranscriptItem, { type: "lifecycle" }>,
|
||||
) {
|
||||
return LIFECYCLE_NOISE.has(item.title.toLowerCase());
|
||||
}
|
||||
|
||||
/** Whether an item should contribute to the "Now" summary and headline scan. */
|
||||
export function isMeaningfulItem(item: TranscriptItem): boolean {
|
||||
if (item.type === "tool" && item.renderClass === "suppressed") {
|
||||
return false;
|
||||
}
|
||||
if (item.type === "lifecycle") {
|
||||
return !isLifecycleNoise(item);
|
||||
}
|
||||
if (item.type === "metadata") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -20,44 +20,107 @@ export type ConnectionState =
|
||||
|
||||
export type ToolStatus = "executing" | "completed" | "failed" | "pending";
|
||||
|
||||
export type AgentActivityRenderClass =
|
||||
| "message"
|
||||
| "relay-op"
|
||||
| "file-edit"
|
||||
| "shell"
|
||||
| "status"
|
||||
| "thought"
|
||||
| "plan"
|
||||
| "permission"
|
||||
| "error"
|
||||
| "generic"
|
||||
| "raw-rail"
|
||||
| "suppressed";
|
||||
|
||||
export type AgentActivityTone = "read" | "write" | "admin" | "neutral";
|
||||
|
||||
export type AgentActivityAction = {
|
||||
verb: string;
|
||||
object?: string | null;
|
||||
};
|
||||
|
||||
export type AgentActivityDescriptor = {
|
||||
renderClass: AgentActivityRenderClass;
|
||||
label: string;
|
||||
preview: string | null;
|
||||
action?: AgentActivityAction;
|
||||
tone?: AgentActivityTone;
|
||||
operation?: string;
|
||||
object?: string | null;
|
||||
source?: "mcp" | "shell" | "acp" | "harness" | "fallback";
|
||||
groupKey?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/** Observer/ACP wire label for dev-only transcript debugging. */
|
||||
export type TranscriptAcpSource = string;
|
||||
|
||||
/** Shared optional identity fields attached during transcript construction. */
|
||||
export type TranscriptItemIdentity = {
|
||||
turnId?: string | null;
|
||||
sessionId?: string | null;
|
||||
channelId?: string | null;
|
||||
};
|
||||
|
||||
export type TranscriptItem =
|
||||
| {
|
||||
| ({
|
||||
id: string;
|
||||
type: "message";
|
||||
renderClass: "message";
|
||||
role: "assistant" | "user";
|
||||
title: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
messageId?: string | null;
|
||||
acpSource?: TranscriptAcpSource;
|
||||
authorPubkey?: string | null;
|
||||
channelId?: string | null;
|
||||
}
|
||||
| {
|
||||
} & TranscriptItemIdentity)
|
||||
| ({
|
||||
id: string;
|
||||
type: "thought";
|
||||
renderClass: "thought";
|
||||
title: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
channelId?: string | null;
|
||||
}
|
||||
| {
|
||||
acpSource?: TranscriptAcpSource;
|
||||
} & TranscriptItemIdentity)
|
||||
| ({
|
||||
id: string;
|
||||
type: "plan";
|
||||
renderClass: "plan";
|
||||
title: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
isUpdate?: boolean;
|
||||
targetId?: string;
|
||||
acpSource?: TranscriptAcpSource;
|
||||
} & TranscriptItemIdentity)
|
||||
| ({
|
||||
id: string;
|
||||
type: "lifecycle";
|
||||
renderClass: "status" | "permission" | "error";
|
||||
title: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
channelId?: string | null;
|
||||
}
|
||||
| {
|
||||
descriptor?: AgentActivityDescriptor;
|
||||
acpSource?: TranscriptAcpSource;
|
||||
} & TranscriptItemIdentity)
|
||||
| ({
|
||||
id: string;
|
||||
type: "metadata";
|
||||
renderClass: "raw-rail";
|
||||
title: string;
|
||||
sections: PromptSection[];
|
||||
timestamp: string;
|
||||
channelId?: string | null;
|
||||
}
|
||||
| {
|
||||
acpSource?: TranscriptAcpSource;
|
||||
} & TranscriptItemIdentity)
|
||||
| ({
|
||||
id: string;
|
||||
type: "tool";
|
||||
renderClass: AgentActivityRenderClass;
|
||||
descriptor: AgentActivityDescriptor;
|
||||
title: string;
|
||||
toolName: string;
|
||||
buzzToolName: string | null;
|
||||
@@ -68,8 +131,8 @@ export type TranscriptItem =
|
||||
timestamp: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
channelId?: string | null;
|
||||
};
|
||||
acpSource?: TranscriptAcpSource;
|
||||
} & TranscriptItemIdentity);
|
||||
|
||||
export type PromptSection = {
|
||||
title: string;
|
||||
|
||||
@@ -50,6 +50,44 @@ export function formatCodeValue(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export type ShellToolOutput = {
|
||||
exitCode: number | null;
|
||||
raw: string;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
timedOut: boolean;
|
||||
};
|
||||
|
||||
export function parseShellToolOutput(result: string): ShellToolOutput {
|
||||
const parsed = parseToolResultValue(result);
|
||||
const record = asRecord(parsed);
|
||||
const hasShellShape =
|
||||
"stdout" in record ||
|
||||
"stderr" in record ||
|
||||
"exit_code" in record ||
|
||||
"exitCode" in record ||
|
||||
"timed_out" in record ||
|
||||
"timedOut" in record;
|
||||
|
||||
if (!hasShellShape) {
|
||||
return {
|
||||
exitCode: null,
|
||||
raw: typeof parsed === "string" ? parsed : result,
|
||||
stderr: "",
|
||||
stdout: "",
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: getToolNumber(record, ["exit_code", "exitCode"]),
|
||||
raw: "",
|
||||
stderr: getOptionalString(record, ["stderr"]),
|
||||
stdout: getOptionalString(record, ["stdout"]),
|
||||
timedOut: getOptionalBoolean(record, ["timed_out", "timedOut"]),
|
||||
};
|
||||
}
|
||||
|
||||
export function titleCase(value: string): string {
|
||||
return value
|
||||
.replace(/[_-]+/g, " ")
|
||||
@@ -64,6 +102,117 @@ export function asRecord(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a tool image source is an inline `data:image/` URI that should be
|
||||
* rendered as-is. This is the dual-layer image-scheme guard: only the
|
||||
* `data:image/` prefix is treated as a safe passthrough — every other scheme
|
||||
* (including other `data:` subtypes) must be routed through the relay rewriter.
|
||||
* Never widen this beyond `data:image/`.
|
||||
*/
|
||||
export function isInlineImageData(source: string): boolean {
|
||||
return source.startsWith("data:image/");
|
||||
}
|
||||
|
||||
function getToolNumber(
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOptionalBoolean(
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getOptionalString(
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Format a millisecond duration; negative input yields null. */
|
||||
export function formatDurationMs(ms: number): string | null {
|
||||
if (ms < 0) return null;
|
||||
const totalSeconds = ms / 1000;
|
||||
if (totalSeconds < 60) {
|
||||
return totalSeconds < 10
|
||||
? `${totalSeconds.toFixed(1)}s`
|
||||
: `${Math.round(totalSeconds)}s`;
|
||||
}
|
||||
let minutes = Math.floor(totalSeconds / 60);
|
||||
let seconds = Math.round(totalSeconds % 60);
|
||||
if (seconds === 60) {
|
||||
minutes += 1;
|
||||
seconds = 0;
|
||||
}
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a tool result string into a value. Handles the double-encoding case
|
||||
* where a JSON string itself contains JSON. Returns null on empty or invalid
|
||||
* input.
|
||||
*/
|
||||
export function parseToolResultValue(result: string): unknown {
|
||||
const trimmed = result.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "string") return parsed;
|
||||
try {
|
||||
return JSON.parse(parsed);
|
||||
} catch {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool's display duration. Prefers the start/complete timestamps,
|
||||
* then falls back to `duration_ms`/`elapsed_ms` fields inside the parsed
|
||||
* result payload.
|
||||
*/
|
||||
export function getToolDurationDisplay(item: {
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
result: string;
|
||||
}): string | null {
|
||||
if (item.startedAt && item.completedAt) {
|
||||
return formatDuration(item.startedAt, item.completedAt);
|
||||
}
|
||||
|
||||
const resultRecord = asRecord(parseToolResultValue(item.result));
|
||||
const durationMs =
|
||||
getToolNumber(resultRecord, ["duration_ms", "durationMs"]) ??
|
||||
getToolNumber(resultRecord, ["elapsed_ms", "elapsedMs"]);
|
||||
return durationMs == null ? null : formatDurationMs(durationMs);
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
@@ -80,14 +229,16 @@ export function shortenMiddle(value: string, maxLength: number) {
|
||||
return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`;
|
||||
}
|
||||
|
||||
const sameDayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
const transcriptTimeFormat = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
hour12: true,
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
const transcriptTitleTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
@@ -97,14 +248,15 @@ const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
export function formatTranscriptTime(isoTimestamp: string): string | null {
|
||||
const date = new Date(isoTimestamp);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
return sameDay
|
||||
? sameDayTimeFormat.format(date)
|
||||
: crossDayTimeFormat.format(date);
|
||||
return transcriptTimeFormat.format(date);
|
||||
}
|
||||
|
||||
export function formatTranscriptTimestampTitle(
|
||||
isoTimestamp: string,
|
||||
): string | undefined {
|
||||
const date = new Date(isoTimestamp);
|
||||
if (Number.isNaN(date.getTime())) return isoTimestamp || undefined;
|
||||
return transcriptTitleTimeFormat.format(date);
|
||||
}
|
||||
|
||||
export function formatDuration(
|
||||
|
||||
42
desktop/src/features/agents/ui/rawEventRail.test.mjs
Normal file
42
desktop/src/features/agents/ui/rawEventRail.test.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { describeRawEvent } from "./agentSessionTranscriptHelpers.ts";
|
||||
|
||||
function rawEvent(overrides = {}) {
|
||||
return {
|
||||
seq: 1,
|
||||
kind: "acp",
|
||||
sessionId: "sess-1",
|
||||
channelId: "channel-1",
|
||||
payload: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("describeRawEvent surfaces the session/update sessionUpdate label", () => {
|
||||
const event = rawEvent({
|
||||
payload: {
|
||||
method: "session/update",
|
||||
params: { update: { sessionUpdate: "agent_message_chunk" } },
|
||||
},
|
||||
});
|
||||
assert.equal(describeRawEvent(event), "agent_message_chunk");
|
||||
});
|
||||
|
||||
test("describeRawEvent falls back to the method when session/update lacks an update label", () => {
|
||||
const event = rawEvent({
|
||||
payload: { method: "session/update", params: {} },
|
||||
});
|
||||
assert.equal(describeRawEvent(event), "session/update");
|
||||
});
|
||||
|
||||
test("describeRawEvent uses the method for non-session/update payloads", () => {
|
||||
const event = rawEvent({ payload: { method: "session/prompt" } });
|
||||
assert.equal(describeRawEvent(event), "session/prompt");
|
||||
});
|
||||
|
||||
test("describeRawEvent falls back to the event kind when no method is present", () => {
|
||||
const event = rawEvent({ kind: "acp_parse_error", payload: {} });
|
||||
assert.equal(describeRawEvent(event), "acp_parse_error");
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Octagon, Settings } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Octagon, Settings, TerminalSquare } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
|
||||
@@ -20,8 +21,10 @@ import { Button } from "@/shared/ui/button";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
|
||||
@@ -59,7 +62,19 @@ export function AgentSessionThreadPanel({
|
||||
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
||||
|
||||
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
|
||||
|
||||
const rawFeedScopeKey = `${agent.pubkey}:${channel?.id ?? "all"}`;
|
||||
const [rawFeedState, setRawFeedState] = React.useState(() => ({
|
||||
scopeKey: rawFeedScopeKey,
|
||||
show: false,
|
||||
}));
|
||||
const showRawFeed =
|
||||
rawFeedState.scopeKey === rawFeedScopeKey && rawFeedState.show;
|
||||
const handleRawFeedChange = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
setRawFeedState({ scopeKey: rawFeedScopeKey, show: checked });
|
||||
},
|
||||
[rawFeedScopeKey],
|
||||
);
|
||||
async function handleInterruptTurn() {
|
||||
if (!channel) {
|
||||
return;
|
||||
@@ -81,7 +96,7 @@ export function AgentSessionThreadPanel({
|
||||
|
||||
const agentHeaderActions = (
|
||||
<AuxiliaryPanelHeaderActions>
|
||||
{isLive && isWorking ? (
|
||||
{isLive ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -108,6 +123,30 @@ export function AgentSessionThreadPanel({
|
||||
className="min-w-56"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showRawFeed}
|
||||
className="items-start gap-3"
|
||||
data-testid="agent-session-toggle-raw-feed"
|
||||
onCheckedChange={handleRawFeedChange}
|
||||
title={
|
||||
showRawFeed
|
||||
? "Hide raw JSON-RPC payloads."
|
||||
: channel
|
||||
? "Show raw JSON-RPC payloads for this channel."
|
||||
: "Show raw JSON-RPC payloads for this agent."
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
<TerminalSquare className="h-4 w-4 text-muted-foreground" />
|
||||
Raw
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
Show raw JSON-RPC activity.
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="items-start gap-3"
|
||||
data-testid="agent-session-stop-turn"
|
||||
@@ -118,7 +157,9 @@ export function AgentSessionThreadPanel({
|
||||
title={
|
||||
canStopCurrentTurn
|
||||
? "Interrupt the current ACP turn without stopping the agent process."
|
||||
: "Only locally managed agents can be interrupted from this workspace."
|
||||
: isWorking
|
||||
? "Only locally managed agents can be interrupted from this workspace."
|
||||
: "Available while the agent is working."
|
||||
}
|
||||
>
|
||||
<Octagon className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -128,7 +169,9 @@ export function AgentSessionThreadPanel({
|
||||
</span>
|
||||
{!canStopCurrentTurn ? (
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
Only available for locally managed agents.
|
||||
{isWorking
|
||||
? "Only available for locally managed agents."
|
||||
: "Available while the agent is working."}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -146,7 +189,9 @@ export function AgentSessionThreadPanel({
|
||||
backButtonTestId="agent-session-back"
|
||||
onBack={onBackToProfile}
|
||||
>
|
||||
<AuxiliaryPanelTitle>Activity</AuxiliaryPanelTitle>
|
||||
<AuxiliaryPanelTitle>
|
||||
{showRawFeed ? "Raw ACP Activity" : "Activity"}
|
||||
</AuxiliaryPanelTitle>
|
||||
</AuxiliaryPanelHeaderGroup>
|
||||
{agentHeaderActions}
|
||||
</>
|
||||
@@ -186,8 +231,9 @@ export function AgentSessionThreadPanel({
|
||||
: `Mention ${agent.name} in any channel to see its work here.`
|
||||
}
|
||||
profiles={profiles}
|
||||
rawLayout="exclusive"
|
||||
showHeader={false}
|
||||
showRaw={false}
|
||||
showRaw={showRawFeed}
|
||||
/>
|
||||
</AuxiliaryPanelBody>
|
||||
</AuxiliaryPanel>
|
||||
|
||||
@@ -2,8 +2,10 @@ import * as React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
|
||||
import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes";
|
||||
import { formatToolTitle } from "@/features/agents/ui/agentSessionToolCatalog";
|
||||
import {
|
||||
getActivityHeadline,
|
||||
isMeaningfulItem,
|
||||
} from "@/features/agents/ui/agentSessionTranscriptPresentation";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -27,18 +29,6 @@ const HOVER_OPEN_DELAY_MS = 150;
|
||||
const HOVER_CLOSE_DELAY_MS = 180;
|
||||
const HEADLINE_ROTATION_MS = 2200;
|
||||
|
||||
function getActivityHeadline(item: TranscriptItem): string | null {
|
||||
if (item.type === "tool") {
|
||||
return formatToolTitle(item.buzzToolName ?? item.toolName, item.title);
|
||||
}
|
||||
|
||||
if (item.type === "message") {
|
||||
return item.role === "assistant" ? "Responding" : item.title;
|
||||
}
|
||||
|
||||
return item.title;
|
||||
}
|
||||
|
||||
export function BotActivityComposerAction({
|
||||
agents,
|
||||
channelId = null,
|
||||
@@ -78,7 +68,11 @@ export function BotActivityComposerAction({
|
||||
: transcript;
|
||||
|
||||
for (let i = scopedTranscript.length - 1; i >= 0; i--) {
|
||||
const headline = getActivityHeadline(scopedTranscript[i]);
|
||||
const item = scopedTranscript[i];
|
||||
if (!isMeaningfulItem(item)) {
|
||||
continue;
|
||||
}
|
||||
const headline = getActivityHeadline(item);
|
||||
if (!headline || seen.has(headline)) {
|
||||
continue;
|
||||
}
|
||||
@@ -185,10 +179,11 @@ export function BotActivityComposerAction({
|
||||
"border border-background",
|
||||
isInline
|
||||
? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-3xs"
|
||||
: "!h-5 !w-5 text-3xs",
|
||||
: "shrink-0",
|
||||
)}
|
||||
displayName={agent.name}
|
||||
key={agent.pubkey}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
@@ -248,8 +243,9 @@ export function BotActivityComposerAction({
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={agentAvatarUrl(agent)}
|
||||
className="!h-6 !w-6 shrink-0 text-2xs"
|
||||
className="shrink-0"
|
||||
displayName={agent.name}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" />
|
||||
|
||||
@@ -285,32 +285,13 @@ export function ChannelScreen({
|
||||
: [],
|
||||
[activeChannel],
|
||||
);
|
||||
const messageProfilePubkeys = React.useMemo(
|
||||
() => [
|
||||
...new Set([
|
||||
...messageAuthorPubkeys,
|
||||
...messageMentionPubkeys,
|
||||
...activeDmParticipantPubkeys,
|
||||
...typingEntries.map((entry) => entry.pubkey),
|
||||
]),
|
||||
],
|
||||
[
|
||||
activeDmParticipantPubkeys,
|
||||
messageAuthorPubkeys,
|
||||
messageMentionPubkeys,
|
||||
typingEntries,
|
||||
],
|
||||
);
|
||||
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
|
||||
enabled: messageProfilePubkeys.length > 0,
|
||||
});
|
||||
const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
|
||||
const channelMembers = channelMembersQuery.data;
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const managedAgents = managedAgentsQuery.data ?? [];
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
const relayAgents = relayAgentsQuery.data ?? [];
|
||||
const agentPubkeys = React.useMemo(() => {
|
||||
const knownAgentPubkeys = React.useMemo(() => {
|
||||
const pubkeys = new Set<string>();
|
||||
for (const member of channelMembers ?? []) {
|
||||
if (member.role === "bot" || member.isAgent) {
|
||||
@@ -323,6 +304,31 @@ export function ChannelScreen({
|
||||
for (const agent of relayAgents) {
|
||||
pubkeys.add(normalizePubkey(agent.pubkey));
|
||||
}
|
||||
return pubkeys;
|
||||
}, [channelMembers, managedAgents, relayAgents]);
|
||||
const messageProfilePubkeys = React.useMemo(
|
||||
() => [
|
||||
...new Set([
|
||||
...messageAuthorPubkeys,
|
||||
...messageMentionPubkeys,
|
||||
...activeDmParticipantPubkeys,
|
||||
...knownAgentPubkeys,
|
||||
...typingEntries.map((entry) => entry.pubkey),
|
||||
]),
|
||||
],
|
||||
[
|
||||
activeDmParticipantPubkeys,
|
||||
knownAgentPubkeys,
|
||||
messageAuthorPubkeys,
|
||||
messageMentionPubkeys,
|
||||
typingEntries,
|
||||
],
|
||||
);
|
||||
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
|
||||
enabled: messageProfilePubkeys.length > 0,
|
||||
});
|
||||
const agentPubkeys = React.useMemo(() => {
|
||||
const pubkeys = new Set(knownAgentPubkeys);
|
||||
for (const [pubkey, profile] of Object.entries(
|
||||
messageProfilesQuery.data?.profiles ?? {},
|
||||
)) {
|
||||
@@ -331,7 +337,7 @@ export function ChannelScreen({
|
||||
}
|
||||
}
|
||||
return pubkeys;
|
||||
}, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]);
|
||||
}, [knownAgentPubkeys, messageProfilesQuery.data]);
|
||||
const agentPubkeysPending =
|
||||
activeChannel?.channelType === "dm" &&
|
||||
(channelMembersQuery.isPending ||
|
||||
|
||||
@@ -99,7 +99,7 @@ export function mergeAgentNamesIntoProfiles(
|
||||
relayAgents: RelayAgent[],
|
||||
): UserProfileLookup {
|
||||
const merged = { ...profiles };
|
||||
for (const agent of [...relayAgents, ...managedAgents]) {
|
||||
for (const agent of relayAgents) {
|
||||
const key = normalizePubkey(agent.pubkey);
|
||||
merged[key] = {
|
||||
...merged[key],
|
||||
@@ -109,5 +109,15 @@ export function mergeAgentNamesIntoProfiles(
|
||||
isAgent: true,
|
||||
};
|
||||
}
|
||||
for (const agent of managedAgents) {
|
||||
const key = normalizePubkey(agent.pubkey);
|
||||
merged[key] = {
|
||||
...merged[key],
|
||||
displayName: merged[key]?.displayName || agent.name,
|
||||
avatarUrl: merged[key]?.avatarUrl ?? agent.avatarUrl,
|
||||
nip05Handle: merged[key]?.nip05Handle ?? null,
|
||||
isAgent: true,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -45,10 +45,7 @@ import type {
|
||||
RuntimeConfigSurface,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
type RawIdentity = {
|
||||
pubkey: string;
|
||||
display_name: string;
|
||||
};
|
||||
type RawIdentity = { pubkey: string; display_name: string };
|
||||
|
||||
type RawProfile = {
|
||||
pubkey: string;
|
||||
@@ -206,6 +203,7 @@ export type RawManagedAgent = {
|
||||
max_turn_duration_seconds: number | null;
|
||||
parallelism: number;
|
||||
system_prompt: string | null;
|
||||
avatar_url?: string | null;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
persona_out_of_date: boolean;
|
||||
@@ -869,6 +867,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
||||
maxTurnDurationSeconds: agent.max_turn_duration_seconds,
|
||||
parallelism: agent.parallelism,
|
||||
systemPrompt: agent.system_prompt,
|
||||
avatarUrl: agent.avatar_url ?? null,
|
||||
model: agent.model,
|
||||
// Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
|
||||
provider: agent.provider ?? null,
|
||||
|
||||
@@ -293,6 +293,7 @@ export type ManagedAgent = {
|
||||
maxTurnDurationSeconds: number | null;
|
||||
parallelism: number;
|
||||
systemPrompt: string | null;
|
||||
avatarUrl: string | null;
|
||||
model: string | null;
|
||||
/** LLM inference provider, from the agent's pinned record snapshot. */
|
||||
provider: string | null;
|
||||
|
||||
@@ -65,12 +65,14 @@ import {
|
||||
} from "./markdown/CodeBlock";
|
||||
import { FileCard } from "./markdown/FileCard";
|
||||
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
|
||||
import { MarkdownInput } from "./markdown/MarkdownInput";
|
||||
import { MessageLinkPill } from "./markdown/MessageLinkPill";
|
||||
import { resolveFileCard } from "./markdownFileCard";
|
||||
import type {
|
||||
ImetaEntry,
|
||||
MarkdownProps,
|
||||
MarkdownRuntime,
|
||||
MarkdownVariant,
|
||||
} from "./markdown/types";
|
||||
import { SpoilerInline } from "./markdown/SpoilerInline";
|
||||
import {
|
||||
@@ -1501,12 +1503,19 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
|
||||
}
|
||||
|
||||
function createMarkdownComponents(
|
||||
variant: MarkdownVariant,
|
||||
runtimeRef: React.RefObject<MarkdownRuntime>,
|
||||
interactive = true,
|
||||
mediaInset = false,
|
||||
): Components {
|
||||
const paragraphClassName = "leading-[inherit]";
|
||||
const listItemClassName = "my-1 [&_p]:inline";
|
||||
const listClassName = "space-y-1 pl-6 marker:text-muted-foreground";
|
||||
const paragraphClassName =
|
||||
variant === "tight"
|
||||
? "leading-5"
|
||||
: variant === "compact"
|
||||
? "leading-6"
|
||||
: "leading-[inherit]";
|
||||
const listItemClassName = "[&_p]:inline";
|
||||
const listClassName = "space-y-1 pl-6 marker:text-muted-foreground/80";
|
||||
|
||||
return {
|
||||
spoiler: ({
|
||||
@@ -1699,7 +1708,12 @@ function createMarkdownComponents(
|
||||
if (resolvedSrc?.endsWith(".mp4")) {
|
||||
const entry = src ? imetaByUrl?.get(src) : undefined;
|
||||
return (
|
||||
<span data-block-media="">
|
||||
<span
|
||||
className={cn(
|
||||
mediaInset && "mx-1.5 block max-w-[calc(100%-0.75rem)]",
|
||||
)}
|
||||
data-block-media=""
|
||||
>
|
||||
<MarkdownVideoPlayer
|
||||
key={src ?? resolvedSrc}
|
||||
alt={alt}
|
||||
@@ -1722,6 +1736,7 @@ function createMarkdownComponents(
|
||||
</span>
|
||||
);
|
||||
},
|
||||
input: MarkdownInput,
|
||||
li: ({ children }) => <li className={listItemClassName}>{children}</li>,
|
||||
ol: ({ children }) => (
|
||||
<ol className={cn("list-decimal", listClassName)}>{children}</ol>
|
||||
@@ -1910,14 +1925,17 @@ function createMarkdownComponents(
|
||||
function MarkdownInner({
|
||||
channelNames,
|
||||
className,
|
||||
compact = false,
|
||||
content,
|
||||
customEmoji,
|
||||
imetaByUrl,
|
||||
interactive = true,
|
||||
agentMentionPubkeysByName,
|
||||
mediaInset = false,
|
||||
mentionNames,
|
||||
mentionPubkeysByName,
|
||||
searchQuery,
|
||||
tight = false,
|
||||
videoReviewContext,
|
||||
}: MarkdownProps) {
|
||||
const { channels: rawChannels } = useChannelNavigation();
|
||||
@@ -1958,9 +1976,16 @@ function MarkdownInner({
|
||||
onOpenMessageLink,
|
||||
});
|
||||
|
||||
const variant: MarkdownVariant = tight
|
||||
? "tight"
|
||||
: compact
|
||||
? "compact"
|
||||
: "default";
|
||||
|
||||
const components = React.useMemo(
|
||||
() => createMarkdownComponents(runtimeRef, interactive),
|
||||
[runtimeRef, interactive],
|
||||
() =>
|
||||
createMarkdownComponents(variant, runtimeRef, interactive, mediaInset),
|
||||
[variant, runtimeRef, interactive, mediaInset],
|
||||
);
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
|
||||
@@ -2028,6 +2053,13 @@ function MarkdownInner({
|
||||
"[&>*+hr]:mt-4 [&>hr+*]:mt-4",
|
||||
"[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5",
|
||||
].join(" "),
|
||||
// Variant overrides: density tweaks for agent-session transcript surfaces.
|
||||
// Layered after the base owl-spacing set so tailwind-merge lets the
|
||||
// narrower leading + tighter inter-block gaps win for compact/tight.
|
||||
variant === "compact" &&
|
||||
"leading-6 [&>*+*]:mt-2 [&>*+h1]:mt-3 [&>*+h2]:mt-3 [&>*+h3]:mt-3 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1",
|
||||
variant === "tight" &&
|
||||
"leading-5 [&>*+*]:mt-2 [&>*+h1]:mt-2.5 [&>*+h2]:mt-2.5 [&>*+h3]:mt-2.5 [&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3 [&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3 [&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3 [&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5 [&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -2053,8 +2085,11 @@ export const Markdown = React.memo(
|
||||
(prev, next) =>
|
||||
prev.content === next.content &&
|
||||
prev.className === next.className &&
|
||||
prev.compact === next.compact &&
|
||||
prev.customEmoji === next.customEmoji &&
|
||||
prev.interactive === next.interactive &&
|
||||
prev.mediaInset === next.mediaInset &&
|
||||
prev.tight === next.tight &&
|
||||
prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName &&
|
||||
prev.mentionPubkeysByName === next.mentionPubkeysByName &&
|
||||
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&
|
||||
|
||||
33
desktop/src/shared/ui/markdown/MarkdownInput.tsx
Normal file
33
desktop/src/shared/ui/markdown/MarkdownInput.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
|
||||
type MarkdownInputProps = React.ComponentProps<"input"> & {
|
||||
node?: unknown;
|
||||
};
|
||||
|
||||
export function MarkdownInput({
|
||||
checked,
|
||||
className,
|
||||
node: _node,
|
||||
type,
|
||||
...props
|
||||
}: MarkdownInputProps) {
|
||||
if (type === "checkbox") {
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label={checked ? "Completed task" : "Incomplete task"}
|
||||
checked={Boolean(checked)}
|
||||
className={cn(
|
||||
"pointer-events-none mr-1.5 inline-flex align-[-0.125rem] disabled:opacity-45",
|
||||
className,
|
||||
)}
|
||||
disabled
|
||||
tabIndex={-1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <input {...props} className={className} type={type} />;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export type MarkdownRuntime = {
|
||||
export type MarkdownProps = {
|
||||
channelNames?: string[];
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
content: string;
|
||||
customEmoji?: CustomEmoji[];
|
||||
imetaByUrl?: ImetaLookup;
|
||||
@@ -42,6 +43,10 @@ export type MarkdownProps = {
|
||||
agentMentionPubkeysByName?: Record<string, string>;
|
||||
mentionNames?: string[];
|
||||
mentionPubkeysByName?: Record<string, string>;
|
||||
mediaInset?: boolean;
|
||||
searchQuery?: string;
|
||||
tight?: boolean;
|
||||
videoReviewContext?: VideoReviewContext;
|
||||
};
|
||||
|
||||
export type MarkdownVariant = "default" | "compact" | "tight";
|
||||
|
||||
@@ -10,10 +10,14 @@ const toggleVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input/40 bg-background hover:bg-muted/70",
|
||||
ghost:
|
||||
"bg-transparent hover:bg-muted/70 hover:text-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground",
|
||||
outline:
|
||||
"border border-input/40 bg-background hover:bg-muted/70 data-[state=on]:bg-muted data-[state=on]:text-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-3 min-w-9",
|
||||
xs: "h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-xs font-medium [&_svg]:size-3.5",
|
||||
sm: "h-8 px-2 min-w-8",
|
||||
lg: "h-10 px-3 min-w-10",
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@ type MockCommandAvailability = {
|
||||
type MockManagedAgentSeed = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
personaId?: string | null;
|
||||
status?: RawManagedAgent["status"];
|
||||
channelNames?: string[];
|
||||
@@ -389,6 +390,7 @@ type RawManagedAgent = {
|
||||
max_turn_duration_seconds: number | null;
|
||||
parallelism: number;
|
||||
system_prompt: string | null;
|
||||
avatar_url: string | null;
|
||||
model: string | null;
|
||||
env_vars?: Record<string, string>;
|
||||
status: "running" | "stopped" | "deployed" | "not_deployed";
|
||||
@@ -937,6 +939,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
|
||||
max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null,
|
||||
parallelism: agent.parallelism,
|
||||
system_prompt: agent.system_prompt,
|
||||
avatar_url: agent.avatar_url ?? null,
|
||||
model: agent.model,
|
||||
env_vars: { ...(agent.env_vars ?? {}) },
|
||||
status: agent.status,
|
||||
@@ -1450,6 +1453,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
|
||||
max_turn_duration_seconds: null,
|
||||
parallelism: 1,
|
||||
system_prompt: null,
|
||||
avatar_url: seed.avatarUrl ?? null,
|
||||
model: null,
|
||||
env_vars: {},
|
||||
status,
|
||||
@@ -5670,6 +5674,7 @@ async function handleCreateManagedAgent(
|
||||
max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null,
|
||||
parallelism: args.input.parallelism ?? 1,
|
||||
system_prompt: args.input.systemPrompt?.trim() || null,
|
||||
avatar_url: avatarUrl,
|
||||
model: args.input.model?.trim() || null,
|
||||
env_vars: { ...(args.input.envVars ?? {}) },
|
||||
status: args.input.spawnAfterCreate ? "running" : "stopped",
|
||||
|
||||
@@ -961,6 +961,14 @@ test("shows and clears activity indicators for active channel agents", async ({
|
||||
await expect(page.getByTestId("agent-session-thread-panel")).toContainText(
|
||||
"alice",
|
||||
);
|
||||
await expect(page.getByTestId("agent-transcript-now-summary")).toHaveCount(0);
|
||||
await page.getByTestId("agent-session-settings-menu-trigger").click();
|
||||
await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-session-stop-turn")).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("agent-session-thread-panel")).toContainText(
|
||||
"No ACP activity yet",
|
||||
);
|
||||
await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
|
||||
|
||||
await page.evaluate((pubkey) => {
|
||||
|
||||
@@ -26,6 +26,8 @@ const CASEY_PROFILE_PUBKEY =
|
||||
"1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const PROFILE_ONLY_AGENT_PUBKEY =
|
||||
"8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
|
||||
const UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY =
|
||||
"7777777777777777777777777777777777777777777777777777777777777777";
|
||||
const SYSTEM_MESSAGE_KIND = 40099;
|
||||
|
||||
/** Locator scoped to the mention autocomplete dropdown inside the composer. */
|
||||
@@ -1463,7 +1465,17 @@ test("wave attachment huddle passes the bot DM pubkey", async ({ page }) => {
|
||||
test("wave attachment huddle waits for placeholder profile-only bot data", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { usersBatchDelayMs: 2_000 });
|
||||
await installMockBridge(page, {
|
||||
searchProfiles: [
|
||||
{
|
||||
pubkey: UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY,
|
||||
displayName: "nova",
|
||||
ownerPubkey: TEST_IDENTITIES.tyler.pubkey,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
usersBatchDelayMs: 2_000,
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
@@ -1484,7 +1496,7 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
|
||||
},
|
||||
{
|
||||
kind: SYSTEM_MESSAGE_KIND,
|
||||
targetPubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
||||
targetPubkey: UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY,
|
||||
},
|
||||
);
|
||||
await waitForTimelineSettled(page);
|
||||
@@ -1492,12 +1504,9 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
|
||||
const joinedRow = page
|
||||
.getByTestId("system-message-row")
|
||||
.filter({ hasText: "joined the channel" });
|
||||
const agentChip = joinedRow.locator(
|
||||
"[data-mention].agent-mention-highlight",
|
||||
{
|
||||
hasText: "mira",
|
||||
},
|
||||
);
|
||||
const agentChip = joinedRow.locator("[data-mention]", {
|
||||
hasText: "77777777…7777",
|
||||
});
|
||||
await expect(agentChip).toBeVisible({ timeout: 5_000 });
|
||||
await agentChip.hover();
|
||||
|
||||
@@ -1506,7 +1515,9 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
|
||||
);
|
||||
await expect(profilePopover).toBeVisible();
|
||||
await profilePopover
|
||||
.getByTestId(`user-profile-popover-wave-${PROFILE_ONLY_AGENT_PUBKEY}`)
|
||||
.getByTestId(
|
||||
`user-profile-popover-wave-${UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY}`,
|
||||
)
|
||||
.click();
|
||||
|
||||
const startHuddleButton = page
|
||||
@@ -1518,7 +1529,7 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
|
||||
|
||||
await expect
|
||||
.poll(() => readStartHuddleMemberPubkeys(page))
|
||||
.toEqual(expect.arrayContaining([PROFILE_ONLY_AGENT_PUBKEY]));
|
||||
.toEqual(expect.arrayContaining([UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY]));
|
||||
});
|
||||
|
||||
test("wave attachment huddle waits for delayed bot DM pubkey", async ({
|
||||
|
||||
Reference in New Issue
Block a user