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
|
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
|
||||||
// re-introduced after 826d735fe removal (config-bridge caller still needs it).
|
// re-introduced after 826d735fe removal (config-bridge caller still needs it).
|
||||||
// PGID resolution helper + PID-recycling safety guard added for orphan sweep.
|
// 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],
|
["src-tauri/src/managed_agents/runtime.rs", 2150],
|
||||||
// applyWorkspace reposDir parameter plus the validateReposDir binding,
|
// applyWorkspace reposDir parameter plus the validateReposDir binding,
|
||||||
// threaded through Tauri invokes for configurable repos_dir, plus the
|
// 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],
|
["src/features/channels/readState/readStateManager.ts", 1030],
|
||||||
// Shared UI was added to this guard after splitting globals/markdown so
|
// Shared UI was added to this guard after splitting globals/markdown so
|
||||||
// large shared renderers cannot grow further while follow-up splits land.
|
// 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/VideoPlayer.tsx", 2199],
|
||||||
["src/shared/ui/sidebar.tsx", 1042],
|
["src/shared/ui/sidebar.tsx", 1042],
|
||||||
// Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent
|
// 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
|
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
|
||||||
// fallback. Queued to split into dedicated discovery modules.
|
// fallback. Queued to split into dedicated discovery modules.
|
||||||
["src-tauri/src/commands/agent_models.rs", 1066],
|
["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({
|
await runFileSizeCheck({
|
||||||
|
|||||||
@@ -1539,6 +1539,7 @@ pub fn build_managed_agent_summary(
|
|||||||
max_turn_duration_seconds: record.max_turn_duration_seconds,
|
max_turn_duration_seconds: record.max_turn_duration_seconds,
|
||||||
parallelism: record.parallelism,
|
parallelism: record.parallelism,
|
||||||
system_prompt: record.system_prompt.clone(),
|
system_prompt: record.system_prompt.clone(),
|
||||||
|
avatar_url: record.avatar_url.clone(),
|
||||||
model: record.model.clone(),
|
model: record.model.clone(),
|
||||||
provider: record.provider.clone(),
|
provider: record.provider.clone(),
|
||||||
persona_out_of_date,
|
persona_out_of_date,
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ pub struct ManagedAgentSummary {
|
|||||||
pub max_turn_duration_seconds: Option<u64>,
|
pub max_turn_duration_seconds: Option<u64>,
|
||||||
pub parallelism: u32,
|
pub parallelism: u32,
|
||||||
pub system_prompt: Option<String>,
|
pub system_prompt: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
/// LLM inference provider, from the agent's pinned record snapshot.
|
/// LLM inference provider, from the agent's pinned record snapshot.
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
|
|||||||
@@ -1,428 +1 @@
|
|||||||
import * as React from "react";
|
export { ToolItem } from "./AgentSessionToolItem/ToolItem";
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 * as React from "react";
|
||||||
import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react";
|
import { CheckCheck, ChevronDown, Radio } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||||
resolveUserLabel,
|
|
||||||
type UserProfileLookup,
|
|
||||||
} from "@/features/profile/lib/identity";
|
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Markdown } from "@/shared/ui/markdown";
|
import {
|
||||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
Dialog,
|
||||||
import type { TranscriptItem } from "./agentSessionTypes";
|
DialogContent,
|
||||||
import { ToolItem } from "./AgentSessionToolItem";
|
DialogDescription,
|
||||||
import { formatTranscriptTime } from "./agentSessionUtils";
|
DialogHeader,
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
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({
|
export function AgentSessionTranscriptList({
|
||||||
|
agentAvatarUrl,
|
||||||
agentName,
|
agentName,
|
||||||
|
agentPubkey,
|
||||||
emptyDescription,
|
emptyDescription,
|
||||||
items,
|
items,
|
||||||
profiles,
|
profiles,
|
||||||
}: {
|
}: AgentTranscriptIdentityProps & {
|
||||||
agentName: string;
|
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
items: TranscriptItem[];
|
items: TranscriptItem[];
|
||||||
profiles?: UserProfileLookup;
|
profiles?: UserProfileLookup;
|
||||||
}) {
|
}) {
|
||||||
|
const displayBlocks = React.useMemo(
|
||||||
|
() => buildTranscriptDisplayBlocks(items),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
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" />
|
<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-3 text-sm font-medium">No ACP activity yet</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{emptyDescription}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{emptyDescription}</p>
|
||||||
@@ -35,224 +91,598 @@ export function AgentSessionTranscriptList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="w-full">
|
||||||
<div
|
<div
|
||||||
aria-label="Live ACP transcript"
|
aria-label="Live ACP transcript"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className="w-full py-1"
|
className="flex w-full flex-col gap-2.5"
|
||||||
role="log"
|
role="log"
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{displayBlocks.map((block) => (
|
||||||
<div className="mt-4 first:mt-0 content-visibility-auto" key={item.id}>
|
<div
|
||||||
<TranscriptItemView
|
className="content-visibility-auto"
|
||||||
|
key={getDisplayBlockKey(block)}
|
||||||
|
>
|
||||||
|
<TranscriptDisplayBlockView
|
||||||
|
agentAvatarUrl={agentAvatarUrl}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
item={item}
|
agentPubkey={agentPubkey}
|
||||||
|
block={block}
|
||||||
profiles={profiles}
|
profiles={profiles}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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({
|
const TranscriptItemView = React.memo(function TranscriptItemView({
|
||||||
|
agentAvatarUrl,
|
||||||
agentName,
|
agentName,
|
||||||
|
agentPubkey,
|
||||||
item,
|
item,
|
||||||
profiles,
|
profiles,
|
||||||
}: {
|
}: AgentTranscriptIdentityProps & {
|
||||||
agentName: string;
|
|
||||||
item: TranscriptItem;
|
item: TranscriptItem;
|
||||||
profiles?: UserProfileLookup;
|
profiles?: UserProfileLookup;
|
||||||
}) {
|
}) {
|
||||||
if (item.type === "message") {
|
|
||||||
return (
|
return (
|
||||||
<MessageItem agentName={agentName} item={item} profiles={profiles} />
|
<TranscriptActivityItem
|
||||||
);
|
agentAvatarUrl={agentAvatarUrl}
|
||||||
}
|
agentName={agentName}
|
||||||
if (item.type === "tool") {
|
agentPubkey={agentPubkey}
|
||||||
return <ToolItem item={item} />;
|
item={item}
|
||||||
}
|
profiles={profiles}
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
) : 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,
|
ObserverEvent,
|
||||||
TranscriptItem,
|
TranscriptItem,
|
||||||
} from "./agentSessionTypes";
|
} from "./agentSessionTypes";
|
||||||
|
import {
|
||||||
|
deriveLatestSessionId,
|
||||||
|
resolveDisplayEvents,
|
||||||
|
resolveRawRailLayout,
|
||||||
|
scopeByChannel,
|
||||||
|
} from "./agentSessionPanelLayout";
|
||||||
import { shorten } from "./agentSessionUtils";
|
import { shorten } from "./agentSessionUtils";
|
||||||
import { useObserverEvents, useAgentTranscript } from "./useObserverEvents";
|
import { useObserverEvents, useAgentTranscript } from "./useObserverEvents";
|
||||||
|
|
||||||
type ManagedAgentSessionPanelProps = {
|
type ManagedAgentSessionPanelProps = {
|
||||||
agent: Pick<ManagedAgent, "pubkey" | "name" | "status">;
|
agent: Pick<ManagedAgent, "pubkey" | "name" | "status"> & {
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
};
|
||||||
channelId?: string | null;
|
channelId?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
emptyDescription?: string;
|
emptyDescription?: string;
|
||||||
|
rawLayout?: "responsive" | "exclusive";
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
showRaw?: boolean;
|
showRaw?: boolean;
|
||||||
profiles?: UserProfileLookup;
|
profiles?: UserProfileLookup;
|
||||||
|
rawEventsOverride?: ObserverEvent[];
|
||||||
|
transcriptOverride?: TranscriptItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ManagedAgentSessionPanel({
|
export function ManagedAgentSessionPanel({
|
||||||
@@ -39,9 +50,12 @@ export function ManagedAgentSessionPanel({
|
|||||||
channelId = null,
|
channelId = null,
|
||||||
className,
|
className,
|
||||||
emptyDescription = "Mention this agent in a channel to watch the next turn.",
|
emptyDescription = "Mention this agent in a channel to watch the next turn.",
|
||||||
|
rawLayout = "responsive",
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
showRaw = true,
|
showRaw = true,
|
||||||
profiles,
|
profiles,
|
||||||
|
rawEventsOverride,
|
||||||
|
transcriptOverride,
|
||||||
}: ManagedAgentSessionPanelProps) {
|
}: ManagedAgentSessionPanelProps) {
|
||||||
const hasObserver = isManagedAgentActive(agent);
|
const hasObserver = isManagedAgentActive(agent);
|
||||||
const { connectionState, errorMessage, events } = useObserverEvents(
|
const { connectionState, errorMessage, events } = useObserverEvents(
|
||||||
@@ -51,27 +65,25 @@ export function ManagedAgentSessionPanel({
|
|||||||
const transcript = useAgentTranscript(hasObserver, agent.pubkey);
|
const transcript = useAgentTranscript(hasObserver, agent.pubkey);
|
||||||
|
|
||||||
const scopedTranscript = React.useMemo(
|
const scopedTranscript = React.useMemo(
|
||||||
() =>
|
() => scopeByChannel(transcript, channelId),
|
||||||
channelId
|
|
||||||
? transcript.filter((item) => item.channelId === channelId)
|
|
||||||
: transcript,
|
|
||||||
[channelId, transcript],
|
[channelId, transcript],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const displayTranscript = transcriptOverride ?? scopedTranscript;
|
||||||
|
|
||||||
const scopedEvents = React.useMemo(
|
const scopedEvents = React.useMemo(
|
||||||
() =>
|
() => scopeByChannel(events, channelId),
|
||||||
channelId
|
|
||||||
? events.filter((event) => event.channelId === channelId)
|
|
||||||
: events,
|
|
||||||
[channelId, events],
|
[channelId, events],
|
||||||
);
|
);
|
||||||
|
const displayEvents = React.useMemo(
|
||||||
|
() => resolveDisplayEvents(scopedEvents, rawEventsOverride),
|
||||||
|
[rawEventsOverride, scopedEvents],
|
||||||
|
);
|
||||||
|
|
||||||
const latestSessionId = React.useMemo(() => {
|
const latestSessionId = React.useMemo(
|
||||||
for (let i = scopedEvents.length - 1; i >= 0; i--) {
|
() => deriveLatestSessionId(displayEvents),
|
||||||
if (scopedEvents[i].sessionId) return scopedEvents[i].sessionId;
|
[displayEvents],
|
||||||
}
|
);
|
||||||
return null;
|
|
||||||
}, [scopedEvents]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -83,22 +95,26 @@ export function ManagedAgentSessionPanel({
|
|||||||
{showHeader ? (
|
{showHeader ? (
|
||||||
<SessionHeader
|
<SessionHeader
|
||||||
connectionState={connectionState}
|
connectionState={connectionState}
|
||||||
eventCount={scopedEvents.length}
|
eventCount={displayEvents.length}
|
||||||
hasObserver={hasObserver}
|
hasObserver={hasObserver}
|
||||||
latestSessionId={latestSessionId}
|
latestSessionId={latestSessionId}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SessionBody
|
<SessionBody
|
||||||
|
agentAvatarUrl={agent.avatarUrl ?? null}
|
||||||
agentName={agent.name}
|
agentName={agent.name}
|
||||||
|
agentPubkey={agent.pubkey}
|
||||||
connectionState={connectionState}
|
connectionState={connectionState}
|
||||||
emptyDescription={emptyDescription}
|
emptyDescription={emptyDescription}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
events={scopedEvents}
|
events={displayEvents}
|
||||||
hasObserver={hasObserver}
|
hasObserver={hasObserver}
|
||||||
|
hasTranscriptOverride={transcriptOverride != null}
|
||||||
profiles={profiles}
|
profiles={profiles}
|
||||||
|
rawLayout={rawLayout}
|
||||||
showRaw={showRaw}
|
showRaw={showRaw}
|
||||||
transcript={scopedTranscript}
|
transcript={displayTranscript}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -140,47 +156,76 @@ function SessionHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionBody({
|
function SessionBody({
|
||||||
|
agentAvatarUrl,
|
||||||
agentName,
|
agentName,
|
||||||
|
agentPubkey,
|
||||||
connectionState,
|
connectionState,
|
||||||
emptyDescription,
|
emptyDescription,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
events,
|
events,
|
||||||
hasObserver,
|
hasObserver,
|
||||||
|
hasTranscriptOverride,
|
||||||
profiles,
|
profiles,
|
||||||
|
rawLayout,
|
||||||
showRaw,
|
showRaw,
|
||||||
transcript,
|
transcript,
|
||||||
}: {
|
}: {
|
||||||
|
agentAvatarUrl: string | null;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
|
agentPubkey: string;
|
||||||
connectionState: ConnectionState;
|
connectionState: ConnectionState;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
events: ObserverEvent[];
|
events: ObserverEvent[];
|
||||||
hasObserver: boolean;
|
hasObserver: boolean;
|
||||||
|
hasTranscriptOverride: boolean;
|
||||||
profiles?: UserProfileLookup;
|
profiles?: UserProfileLookup;
|
||||||
|
rawLayout: "responsive" | "exclusive";
|
||||||
showRaw: boolean;
|
showRaw: boolean;
|
||||||
transcript: TranscriptItem[];
|
transcript: TranscriptItem[];
|
||||||
}) {
|
}) {
|
||||||
|
const rawRail = resolveRawRailLayout(showRaw, rawLayout);
|
||||||
|
|
||||||
|
if (rawRail.mode === "exclusive") {
|
||||||
return (
|
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 />
|
<EmptyObserverState />
|
||||||
) : connectionState === "connecting" && events.length === 0 ? (
|
) : connectionState === "connecting" &&
|
||||||
|
events.length === 0 &&
|
||||||
|
!hasTranscriptOverride ? (
|
||||||
<SessionLoadingSkeleton />
|
<SessionLoadingSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
showRaw
|
rawRail.mode === "side"
|
||||||
? "mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
? "mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
||||||
: "mt-0",
|
: "mt-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AgentSessionTranscriptList
|
<AgentSessionTranscriptList
|
||||||
|
agentAvatarUrl={agentAvatarUrl}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
|
agentPubkey={agentPubkey}
|
||||||
emptyDescription={emptyDescription}
|
emptyDescription={emptyDescription}
|
||||||
items={transcript}
|
items={transcript}
|
||||||
profiles={profiles}
|
profiles={profiles}
|
||||||
/>
|
/>
|
||||||
{showRaw ? <RawEventRail events={events} /> : null}
|
{rawRail.mode === "side" ? <RawEventRail events={events} /> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,28 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/ui/button";
|
|
||||||
import type { ObserverEvent } from "./agentSessionTypes";
|
import type { ObserverEvent } from "./agentSessionTypes";
|
||||||
import { describeRawEvent } from "./agentSessionTranscript";
|
import { describeRawEvent } from "./agentSessionTranscript";
|
||||||
|
|
||||||
export function RawEventRail({ events }: { events: ObserverEvent[] }) {
|
export function RawEventRail({ events }: { events: ObserverEvent[] }) {
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
|
||||||
const visible = expanded ? events : events.slice(-18);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="rounded-lg border border-border/70 bg-[#17171d] text-zinc-100">
|
<section className="flex min-h-0 w-full flex-col text-foreground">
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
|
<div className="min-h-0 flex-1">
|
||||||
<div>
|
{events.length === 0 ? (
|
||||||
<p className="text-2xs font-semibold uppercase tracking-[0.16em] text-zinc-400">
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
Raw ACP
|
No raw events yet.
|
||||||
</p>
|
</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">
|
<div className="space-y-2">
|
||||||
{visible.map((event) => (
|
{events.map((event) => (
|
||||||
<details
|
<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}
|
key={event.seq}
|
||||||
>
|
>
|
||||||
<summary className="cursor-pointer select-none text-xs text-zinc-300">
|
<summary className="cursor-pointer select-none text-xs text-muted-foreground transition-colors group-open:text-foreground">
|
||||||
<span className="font-mono text-zinc-500">#{event.seq}</span>{" "}
|
<span className="font-mono text-muted-foreground/70">
|
||||||
|
#{event.seq}
|
||||||
|
</span>{" "}
|
||||||
{describeRawEvent(event)}
|
{describeRawEvent(event)}
|
||||||
</summary>
|
</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)}
|
{JSON.stringify(event.payload, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
@@ -48,6 +30,6 @@ export function RawEventRail({ events }: { events: ObserverEvent[] }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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",
|
sessionId: "sess-1",
|
||||||
turnId: "turn-1",
|
turnId: "turn-1",
|
||||||
};
|
};
|
||||||
|
const PROMPT_EVENT_ID = "c".repeat(64);
|
||||||
|
|
||||||
function acpToolUpdate(seq, update) {
|
function acpToolUpdate(seq, update) {
|
||||||
return {
|
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: "[Context]\nScope: thread" },
|
||||||
{
|
{
|
||||||
type: "text",
|
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"],
|
["Agent Memory — core", "Context", "Buzz event: @mention"],
|
||||||
"every section header is counted",
|
"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", () => {
|
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.deepEqual(item.args, { limit: 20 });
|
||||||
assert.equal(item.status, "completed");
|
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 {
|
import type {
|
||||||
|
AgentActivityDescriptor,
|
||||||
|
AgentActivityRenderClass,
|
||||||
ObserverEvent,
|
ObserverEvent,
|
||||||
PromptSection,
|
PromptSection,
|
||||||
ToolStatus,
|
ToolStatus,
|
||||||
@@ -9,13 +11,15 @@ import {
|
|||||||
isGenericToolTitle,
|
isGenericToolTitle,
|
||||||
normalizeToolStatus,
|
normalizeToolStatus,
|
||||||
} from "./agentSessionToolCatalog";
|
} from "./agentSessionToolCatalog";
|
||||||
import { asRecord, asString } from "./agentSessionUtils";
|
import { classifyTool } from "./agentSessionToolClassifier";
|
||||||
|
import { asRecord, asString, titleCase } from "./agentSessionUtils";
|
||||||
import {
|
import {
|
||||||
describeTurnStarted,
|
describeTurnStarted,
|
||||||
describeSessionResolved,
|
describeSessionResolved,
|
||||||
extractBlockText,
|
extractBlockText,
|
||||||
extractContentText,
|
extractContentText,
|
||||||
extractPromptText,
|
extractPromptText,
|
||||||
|
extractTriggeringEventIds,
|
||||||
extractToolArgs,
|
extractToolArgs,
|
||||||
extractToolIdentity,
|
extractToolIdentity,
|
||||||
extractToolResult,
|
extractToolResult,
|
||||||
@@ -30,6 +34,7 @@ export type TranscriptState = {
|
|||||||
itemsById: Map<string, TranscriptItem>;
|
itemsById: Map<string, TranscriptItem>;
|
||||||
activeMessageKey: Map<string, string>;
|
activeMessageKey: Map<string, string>;
|
||||||
sealedKeys: Set<string>;
|
sealedKeys: Set<string>;
|
||||||
|
triggeringEventIdsByTurn: Map<string, string[]>;
|
||||||
continuationSeq: number;
|
continuationSeq: number;
|
||||||
latestSessionId: string | null;
|
latestSessionId: string | null;
|
||||||
};
|
};
|
||||||
@@ -40,6 +45,7 @@ export function createEmptyTranscriptState(): TranscriptState {
|
|||||||
itemsById: new Map(),
|
itemsById: new Map(),
|
||||||
activeMessageKey: new Map(),
|
activeMessageKey: new Map(),
|
||||||
sealedKeys: new Set(),
|
sealedKeys: new Set(),
|
||||||
|
triggeringEventIdsByTurn: new Map(),
|
||||||
continuationSeq: 0,
|
continuationSeq: 0,
|
||||||
latestSessionId: null,
|
latestSessionId: null,
|
||||||
};
|
};
|
||||||
@@ -55,6 +61,7 @@ type TranscriptDraft = {
|
|||||||
itemsById: Map<string, TranscriptItem>;
|
itemsById: Map<string, TranscriptItem>;
|
||||||
activeMessageKey: Map<string, string>;
|
activeMessageKey: Map<string, string>;
|
||||||
sealedKeys: Set<string>;
|
sealedKeys: Set<string>;
|
||||||
|
triggeringEventIdsByTurn: Map<string, string[]>;
|
||||||
continuationSeq: number;
|
continuationSeq: number;
|
||||||
latestSessionId: string | null;
|
latestSessionId: string | null;
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
@@ -66,6 +73,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft {
|
|||||||
itemsById: state.itemsById,
|
itemsById: state.itemsById,
|
||||||
activeMessageKey: state.activeMessageKey,
|
activeMessageKey: state.activeMessageKey,
|
||||||
sealedKeys: state.sealedKeys,
|
sealedKeys: state.sealedKeys,
|
||||||
|
triggeringEventIdsByTurn: state.triggeringEventIdsByTurn,
|
||||||
continuationSeq: state.continuationSeq,
|
continuationSeq: state.continuationSeq,
|
||||||
latestSessionId: state.latestSessionId,
|
latestSessionId: state.latestSessionId,
|
||||||
changed: false,
|
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(
|
function upsertMessage(
|
||||||
d: TranscriptDraft,
|
d: TranscriptDraft,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -116,8 +222,10 @@ function upsertMessage(
|
|||||||
title: string,
|
title: string,
|
||||||
text: string,
|
text: string,
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
channelId: string | null,
|
ctx: TranscriptItemContext,
|
||||||
authorPubkey: string | null = null,
|
authorPubkey: string | null = null,
|
||||||
|
acpSource?: string,
|
||||||
|
messageId: string | null = null,
|
||||||
) {
|
) {
|
||||||
const currentKey = d.activeMessageKey.get(id);
|
const currentKey = d.activeMessageKey.get(id);
|
||||||
|
|
||||||
@@ -127,8 +235,12 @@ function upsertMessage(
|
|||||||
replaceItem(d, currentKey, {
|
replaceItem(d, currentKey, {
|
||||||
...existing,
|
...existing,
|
||||||
text: existing.text + text,
|
text: existing.text + text,
|
||||||
channelId,
|
channelId: ctx.channelId,
|
||||||
|
turnId: ctx.turnId ?? existing.turnId,
|
||||||
|
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||||
authorPubkey: authorPubkey ?? existing.authorPubkey,
|
authorPubkey: authorPubkey ?? existing.authorPubkey,
|
||||||
|
acpSource: acpSource ?? existing.acpSource,
|
||||||
|
messageId: messageId ?? existing.messageId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -139,12 +251,17 @@ function upsertMessage(
|
|||||||
pushItem(d, {
|
pushItem(d, {
|
||||||
id: newKey,
|
id: newKey,
|
||||||
type: "message",
|
type: "message",
|
||||||
|
renderClass: "message",
|
||||||
role,
|
role,
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
channelId,
|
messageId,
|
||||||
|
channelId: ctx.channelId,
|
||||||
|
turnId: ctx.turnId,
|
||||||
|
sessionId: ctx.sessionId,
|
||||||
authorPubkey,
|
authorPubkey,
|
||||||
|
acpSource,
|
||||||
});
|
});
|
||||||
d.activeMessageKey = new Map(d.activeMessageKey);
|
d.activeMessageKey = new Map(d.activeMessageKey);
|
||||||
d.activeMessageKey.set(id, newKey);
|
d.activeMessageKey.set(id, newKey);
|
||||||
@@ -157,15 +274,172 @@ function upsertTextItem(
|
|||||||
title: string,
|
title: string,
|
||||||
text: string,
|
text: string,
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
channelId: string | null,
|
ctx: TranscriptItemContext,
|
||||||
|
acpSource?: string,
|
||||||
) {
|
) {
|
||||||
const existing = d.itemsById.get(id);
|
const existing = d.itemsById.get(id);
|
||||||
if (existing && existing.type === type) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
sealOpenMessages(d);
|
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(
|
function upsertMetadata(
|
||||||
@@ -174,15 +448,46 @@ function upsertMetadata(
|
|||||||
title: string,
|
title: string,
|
||||||
sections: PromptSection[],
|
sections: PromptSection[],
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
channelId: string | null,
|
ctx: TranscriptItemContext,
|
||||||
|
acpSource?: string,
|
||||||
) {
|
) {
|
||||||
const existing = d.itemsById.get(id);
|
const existing = d.itemsById.get(id);
|
||||||
if (existing?.type === "metadata") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
sealOpenMessages(d);
|
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(
|
function upsertTool(
|
||||||
@@ -196,7 +501,8 @@ function upsertTool(
|
|||||||
result: string,
|
result: string,
|
||||||
isError: boolean,
|
isError: boolean,
|
||||||
timestamp: string,
|
timestamp: string,
|
||||||
channelId: string | null,
|
ctx: TranscriptItemContext,
|
||||||
|
acpSource?: string,
|
||||||
) {
|
) {
|
||||||
const existing = d.itemsById.get(id);
|
const existing = d.itemsById.get(id);
|
||||||
const canonicalBuzzToolName =
|
const canonicalBuzzToolName =
|
||||||
@@ -211,30 +517,57 @@ function upsertTool(
|
|||||||
} else if (!existing.buzzToolName && !isGenericToolTitle(toolName)) {
|
} else if (!existing.buzzToolName && !isGenericToolTitle(toolName)) {
|
||||||
updatedToolName = toolName;
|
updatedToolName = toolName;
|
||||||
}
|
}
|
||||||
replaceItem(d, id, {
|
const mergedStatus = mergeToolStatus(existing.status, status);
|
||||||
...existing,
|
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,
|
title: updatedTitle,
|
||||||
toolName: updatedToolName,
|
toolName: updatedToolName,
|
||||||
buzzToolName: updatedBuzzToolName,
|
buzzToolName: updatedBuzzToolName,
|
||||||
status,
|
args: updatedArgs,
|
||||||
args: Object.keys(args).length > 0 ? args : existing.args,
|
result: updatedResult,
|
||||||
result: result || existing.result,
|
isError: updatedIsError || mergedStatus === "failed",
|
||||||
isError: isError || existing.isError,
|
});
|
||||||
|
replaceItem(d, id, {
|
||||||
|
...existing,
|
||||||
|
renderClass: descriptor.renderClass,
|
||||||
|
descriptor,
|
||||||
|
title: updatedTitle,
|
||||||
|
toolName: updatedToolName,
|
||||||
|
buzzToolName: updatedBuzzToolName,
|
||||||
|
status: mergedStatus,
|
||||||
|
args: updatedArgs,
|
||||||
|
result: updatedResult,
|
||||||
|
isError: updatedIsError,
|
||||||
completedAt:
|
completedAt:
|
||||||
(status === "completed" || status === "failed") &&
|
isTerminalToolStatus(mergedStatus) && existing.completedAt == null
|
||||||
existing.completedAt == null
|
|
||||||
? timestamp
|
? timestamp
|
||||||
: existing.completedAt,
|
: existing.completedAt,
|
||||||
channelId,
|
channelId: ctx.channelId,
|
||||||
|
turnId: ctx.turnId ?? existing.turnId,
|
||||||
|
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||||
|
acpSource: acpSource ?? existing.acpSource,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const resolvedToolName = canonicalBuzzToolName ?? toolName;
|
||||||
|
const descriptor = classifyTool({
|
||||||
|
title,
|
||||||
|
toolName: resolvedToolName,
|
||||||
|
buzzToolName: canonicalBuzzToolName,
|
||||||
|
args,
|
||||||
|
result,
|
||||||
|
isError: isError || status === "failed",
|
||||||
|
});
|
||||||
sealOpenMessages(d);
|
sealOpenMessages(d);
|
||||||
pushItem(d, {
|
pushItem(d, {
|
||||||
id,
|
id,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
|
renderClass: descriptor.renderClass,
|
||||||
|
descriptor,
|
||||||
title,
|
title,
|
||||||
toolName: canonicalBuzzToolName ?? toolName,
|
toolName: resolvedToolName,
|
||||||
buzzToolName: canonicalBuzzToolName,
|
buzzToolName: canonicalBuzzToolName,
|
||||||
status,
|
status,
|
||||||
args,
|
args,
|
||||||
@@ -242,8 +575,11 @@ function upsertTool(
|
|||||||
isError,
|
isError,
|
||||||
timestamp,
|
timestamp,
|
||||||
startedAt: timestamp,
|
startedAt: timestamp,
|
||||||
completedAt: null,
|
completedAt: isTerminalToolStatus(status) ? timestamp : null,
|
||||||
channelId,
|
channelId: ctx.channelId,
|
||||||
|
turnId: ctx.turnId,
|
||||||
|
sessionId: ctx.sessionId,
|
||||||
|
acpSource,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,8 +595,34 @@ export function processTranscriptEvent(
|
|||||||
|
|
||||||
const channelId = event.channelId ?? null;
|
const channelId = event.channelId ?? null;
|
||||||
const ch = channelId ?? "global";
|
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(
|
upsertTextItem(
|
||||||
d,
|
d,
|
||||||
`turn:${ch}:${event.turnId ?? event.seq}`,
|
`turn:${ch}:${event.turnId ?? event.seq}`,
|
||||||
@@ -268,7 +630,8 @@ export function processTranscriptEvent(
|
|||||||
"Turn started",
|
"Turn started",
|
||||||
describeTurnStarted(event.payload),
|
describeTurnStarted(event.payload),
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
event.kind,
|
||||||
);
|
);
|
||||||
} else if (event.kind === "session_resolved") {
|
} else if (event.kind === "session_resolved") {
|
||||||
upsertTextItem(
|
upsertTextItem(
|
||||||
@@ -278,7 +641,8 @@ export function processTranscriptEvent(
|
|||||||
"Session ready",
|
"Session ready",
|
||||||
describeSessionResolved(event.payload),
|
describeSessionResolved(event.payload),
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
event.kind,
|
||||||
);
|
);
|
||||||
} else if (event.kind === "acp_parse_error") {
|
} else if (event.kind === "acp_parse_error") {
|
||||||
upsertTextItem(
|
upsertTextItem(
|
||||||
@@ -288,7 +652,8 @@ export function processTranscriptEvent(
|
|||||||
"Wire parse error",
|
"Wire parse error",
|
||||||
extractBlockText(event.payload),
|
extractBlockText(event.payload),
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
event.kind,
|
||||||
);
|
);
|
||||||
} else if (event.kind === "turn_error" || event.kind === "agent_panic") {
|
} else if (event.kind === "turn_error" || event.kind === "agent_panic") {
|
||||||
const payload = asRecord(event.payload);
|
const payload = asRecord(event.payload);
|
||||||
@@ -303,13 +668,27 @@ export function processTranscriptEvent(
|
|||||||
title,
|
title,
|
||||||
`${outcome}: ${error}`,
|
`${outcome}: ${error}`,
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
event.kind,
|
||||||
);
|
);
|
||||||
} else if (event.kind === "acp_read" || event.kind === "acp_write") {
|
} else if (event.kind === "acp_read" || event.kind === "acp_write") {
|
||||||
const payload = asRecord(event.payload);
|
const payload = asRecord(event.payload);
|
||||||
const method = asString(payload.method);
|
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);
|
const promptText = extractPromptText(payload);
|
||||||
if (promptText) {
|
if (promptText) {
|
||||||
const parsedPrompt = parsePromptText(promptText);
|
const parsedPrompt = parsePromptText(promptText);
|
||||||
@@ -321,8 +700,11 @@ export function processTranscriptEvent(
|
|||||||
parsedPrompt.userTitle,
|
parsedPrompt.userTitle,
|
||||||
parsedPrompt.userText,
|
parsedPrompt.userText,
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
parsedPrompt.userPubkey,
|
parsedPrompt.userPubkey,
|
||||||
|
"session/prompt:user",
|
||||||
|
parsedPrompt.userEventId ??
|
||||||
|
getSingleTriggeringEventId(d, ch, event.turnId ?? event.seq),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (parsedPrompt.sections.length > 0) {
|
if (parsedPrompt.sections.length > 0) {
|
||||||
@@ -332,7 +714,8 @@ export function processTranscriptEvent(
|
|||||||
"Prompt context",
|
"Prompt context",
|
||||||
parsedPrompt.sections,
|
parsedPrompt.sections,
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
"session/prompt:context",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,7 +736,7 @@ export function processTranscriptEvent(
|
|||||||
"System prompt",
|
"System prompt",
|
||||||
sections,
|
sections,
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,8 +755,10 @@ export function processTranscriptEvent(
|
|||||||
parsedPrompt.userTitle,
|
parsedPrompt.userTitle,
|
||||||
parsedPrompt.userText,
|
parsedPrompt.userText,
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
parsedPrompt.userPubkey,
|
parsedPrompt.userPubkey,
|
||||||
|
undefined,
|
||||||
|
parsedPrompt.userEventId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (parsedPrompt.sections.length > 0) {
|
if (parsedPrompt.sections.length > 0) {
|
||||||
@@ -383,7 +768,7 @@ export function processTranscriptEvent(
|
|||||||
"Prompt context",
|
"Prompt context",
|
||||||
parsedPrompt.sections,
|
parsedPrompt.sections,
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,13 +787,17 @@ export function processTranscriptEvent(
|
|||||||
"Assistant",
|
"Assistant",
|
||||||
extractContentText(update.content),
|
extractContentText(update.content),
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
null,
|
||||||
|
updateType,
|
||||||
);
|
);
|
||||||
} else if (updateType === "user_message_chunk") {
|
} else if (updateType === "user_message_chunk") {
|
||||||
// Suppress user_message_chunk echo when a steer already rendered
|
// Suppress user_message_chunk echo when a steer already rendered
|
||||||
// the user message for this turn (Goose echoes steered content back).
|
// the user message for this turn (Goose echoes steered content back).
|
||||||
const steerKey = `steer:${ch}:${event.turnId ?? event.seq}`;
|
const steerKey = `steer:${ch}:${event.turnId ?? event.seq}`;
|
||||||
|
const authorPubkey = asString(update.authorPubkey);
|
||||||
if (!d.itemsById.has(steerKey)) {
|
if (!d.itemsById.has(steerKey)) {
|
||||||
|
const channelMessageId = maybeNostrEventId(messageId);
|
||||||
upsertMessage(
|
upsertMessage(
|
||||||
d,
|
d,
|
||||||
`user:${ch}:${messageId ?? turnKey}`,
|
`user:${ch}:${messageId ?? turnKey}`,
|
||||||
@@ -416,7 +805,10 @@ export function processTranscriptEvent(
|
|||||||
"User",
|
"User",
|
||||||
extractContentText(update.content),
|
extractContentText(update.content),
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
authorPubkey,
|
||||||
|
updateType,
|
||||||
|
channelMessageId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (updateType === "agent_thought_chunk") {
|
} else if (updateType === "agent_thought_chunk") {
|
||||||
@@ -427,7 +819,8 @@ export function processTranscriptEvent(
|
|||||||
"Thinking",
|
"Thinking",
|
||||||
extractContentText(update.content),
|
extractContentText(update.content),
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
updateType,
|
||||||
);
|
);
|
||||||
} else if (updateType === "tool_call") {
|
} else if (updateType === "tool_call") {
|
||||||
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
|
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
|
||||||
@@ -443,7 +836,8 @@ export function processTranscriptEvent(
|
|||||||
extractToolResult(update),
|
extractToolResult(update),
|
||||||
false,
|
false,
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
updateType,
|
||||||
);
|
);
|
||||||
} else if (updateType === "tool_call_update") {
|
} else if (updateType === "tool_call_update") {
|
||||||
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
|
const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`;
|
||||||
@@ -462,17 +856,53 @@ export function processTranscriptEvent(
|
|||||||
extractToolResult(update),
|
extractToolResult(update),
|
||||||
status === "failed",
|
status === "failed",
|
||||||
event.timestamp,
|
event.timestamp,
|
||||||
channelId,
|
ctx,
|
||||||
|
updateType,
|
||||||
);
|
);
|
||||||
} else if (updateType === "plan") {
|
} else if (updateType === "plan") {
|
||||||
upsertTextItem(
|
upsertPlan(
|
||||||
d,
|
d,
|
||||||
`plan:${ch}:${turnKey}`,
|
`plan:${ch}:${turnKey}`,
|
||||||
"thought",
|
|
||||||
"Plan",
|
"Plan",
|
||||||
extractContentText(update.content) || JSON.stringify(update, null, 2),
|
extractContentText(update.content) || JSON.stringify(update, null, 2),
|
||||||
event.timestamp,
|
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,
|
itemsById: d.itemsById,
|
||||||
activeMessageKey: d.activeMessageKey,
|
activeMessageKey: d.activeMessageKey,
|
||||||
sealedKeys: d.sealedKeys,
|
sealedKeys: d.sealedKeys,
|
||||||
|
triggeringEventIdsByTurn: d.triggeringEventIdsByTurn,
|
||||||
continuationSeq: d.continuationSeq,
|
continuationSeq: d.continuationSeq,
|
||||||
latestSessionId: d.latestSessionId,
|
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: "",
|
userText: "",
|
||||||
userTitle: "Prompt",
|
userTitle: "Prompt",
|
||||||
userPubkey: null,
|
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.userText, "");
|
||||||
assert.equal(result.userTitle, "Buzz event");
|
assert.equal(result.userTitle, "Buzz event");
|
||||||
assert.equal(result.userPubkey, null);
|
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 = [
|
const text = [
|
||||||
"[System]",
|
"[System]",
|
||||||
"system preamble here",
|
"system preamble here",
|
||||||
"",
|
"",
|
||||||
"[Buzz event: @mention]",
|
"[Buzz event: @mention]",
|
||||||
|
`Event ID: ${HEX_UPPER}`,
|
||||||
"Channel: demo",
|
"Channel: demo",
|
||||||
`From: Wes (hex: ${HEX})`,
|
`From: Wes (hex: ${HEX})`,
|
||||||
"Content: hello @Brain please look",
|
"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.userText, "hello @Brain please look");
|
||||||
assert.equal(result.userPubkey, HEX);
|
assert.equal(result.userPubkey, HEX);
|
||||||
|
assert.equal(result.userEventId, HEX);
|
||||||
// titleCase capitalizes after word boundaries but leaves the leading "@"
|
// titleCase capitalizes after word boundaries but leaves the leading "@"
|
||||||
// (a non-word char) in place: "@mention" -> "@Mention".
|
// (a non-word char) in place: "@mention" -> "@Mention".
|
||||||
assert.equal(result.userTitle, "@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", () => {
|
test("parsePromptText lowercases the extracted hex pubkey", () => {
|
||||||
const text = [
|
const text = [
|
||||||
"[Buzz event: dm]",
|
"[Buzz event: dm]",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function parsePromptText(text: string): {
|
|||||||
userText: string;
|
userText: string;
|
||||||
userTitle: string;
|
userTitle: string;
|
||||||
userPubkey: string | null;
|
userPubkey: string | null;
|
||||||
|
userEventId: string | null;
|
||||||
} {
|
} {
|
||||||
const sections = parsePromptSections(text).filter(
|
const sections = parsePromptSections(text).filter(
|
||||||
(s) => s.body.trim().length > 0,
|
(s) => s.body.trim().length > 0,
|
||||||
@@ -28,12 +29,13 @@ export function parsePromptText(text: string): {
|
|||||||
userText: text.trim(),
|
userText: text.trim(),
|
||||||
userTitle: "Prompt",
|
userTitle: "Prompt",
|
||||||
userPubkey: null,
|
userPubkey: null,
|
||||||
|
userEventId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventSection = sections.find((section) => {
|
const eventSection = sections.find((section) => {
|
||||||
const title = section.title.toLowerCase();
|
const title = section.title.toLowerCase();
|
||||||
return title.startsWith("buzz event") || title.startsWith("buzz event");
|
return title.startsWith("buzz event");
|
||||||
});
|
});
|
||||||
const eventContent = eventSection
|
const eventContent = eventSection
|
||||||
? extractEventContent(eventSection.body)
|
? extractEventContent(eventSection.body)
|
||||||
@@ -41,6 +43,7 @@ export function parsePromptText(text: string): {
|
|||||||
const eventAuthorPubkey = eventSection
|
const eventAuthorPubkey = eventSection
|
||||||
? extractEventAuthorPubkey(eventSection.body)
|
? extractEventAuthorPubkey(eventSection.body)
|
||||||
: null;
|
: null;
|
||||||
|
const eventId = eventSection ? extractEventId(eventSection.body) : null;
|
||||||
const eventKind = eventSection?.title.split(":").slice(1).join(":").trim();
|
const eventKind = eventSection?.title.split(":").slice(1).join(":").trim();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -48,6 +51,7 @@ export function parsePromptText(text: string): {
|
|||||||
userText: eventContent,
|
userText: eventContent,
|
||||||
userTitle: eventKind ? titleCase(eventKind) : "Buzz event",
|
userTitle: eventKind ? titleCase(eventKind) : "Buzz event",
|
||||||
userPubkey: eventAuthorPubkey,
|
userPubkey: eventAuthorPubkey,
|
||||||
|
userEventId: eventId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,9 +131,39 @@ function parsePromptSections(text: string): PromptSection[] {
|
|||||||
return sections;
|
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 {
|
function extractEventContent(body: string): string {
|
||||||
const contentMatch = body.match(/^Content:\s*(.*)$/m);
|
const lines = body.split(/\r?\n/);
|
||||||
return contentMatch?.[1]?.trim() ?? "";
|
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 {
|
function extractEventAuthorPubkey(body: string): string | null {
|
||||||
@@ -137,6 +171,11 @@ function extractEventAuthorPubkey(body: string): string | null {
|
|||||||
return fromMatch?.[1]?.toLowerCase() ?? 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 {
|
export function extractContentText(value: unknown): string {
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
if (Array.isArray(value)) return value.map(extractBlockText).join("\n");
|
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);
|
return extractBlockText(update.rawOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeTurnStarted(payload: unknown): string {
|
export function extractTriggeringEventIds(payload: unknown): string[] {
|
||||||
const record = asRecord(payload);
|
const record = asRecord(payload);
|
||||||
const ids = Array.isArray(record.triggeringEventIds)
|
return Array.isArray(record.triggeringEventIds)
|
||||||
? record.triggeringEventIds.filter(
|
? record.triggeringEventIds.filter(
|
||||||
(id): id is string => typeof id === "string",
|
(id): id is string => typeof id === "string",
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeTurnStarted(payload: unknown): string {
|
||||||
|
const ids = extractTriggeringEventIds(payload);
|
||||||
return ids.length > 0
|
return ids.length > 0
|
||||||
? `Triggered by ${ids.length === 1 ? "1 event" : `${ids.length} events`}.`
|
? `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 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 =
|
export type TranscriptItem =
|
||||||
| {
|
| ({
|
||||||
id: string;
|
id: string;
|
||||||
type: "message";
|
type: "message";
|
||||||
|
renderClass: "message";
|
||||||
role: "assistant" | "user";
|
role: "assistant" | "user";
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
messageId?: string | null;
|
||||||
|
acpSource?: TranscriptAcpSource;
|
||||||
authorPubkey?: string | null;
|
authorPubkey?: string | null;
|
||||||
channelId?: string | null;
|
} & TranscriptItemIdentity)
|
||||||
}
|
| ({
|
||||||
| {
|
|
||||||
id: string;
|
id: string;
|
||||||
type: "thought";
|
type: "thought";
|
||||||
|
renderClass: "thought";
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: 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;
|
id: string;
|
||||||
type: "lifecycle";
|
type: "lifecycle";
|
||||||
|
renderClass: "status" | "permission" | "error";
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
channelId?: string | null;
|
descriptor?: AgentActivityDescriptor;
|
||||||
}
|
acpSource?: TranscriptAcpSource;
|
||||||
| {
|
} & TranscriptItemIdentity)
|
||||||
|
| ({
|
||||||
id: string;
|
id: string;
|
||||||
type: "metadata";
|
type: "metadata";
|
||||||
|
renderClass: "raw-rail";
|
||||||
title: string;
|
title: string;
|
||||||
sections: PromptSection[];
|
sections: PromptSection[];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
channelId?: string | null;
|
acpSource?: TranscriptAcpSource;
|
||||||
}
|
} & TranscriptItemIdentity)
|
||||||
| {
|
| ({
|
||||||
id: string;
|
id: string;
|
||||||
type: "tool";
|
type: "tool";
|
||||||
|
renderClass: AgentActivityRenderClass;
|
||||||
|
descriptor: AgentActivityDescriptor;
|
||||||
title: string;
|
title: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
buzzToolName: string | null;
|
buzzToolName: string | null;
|
||||||
@@ -68,8 +131,8 @@ export type TranscriptItem =
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
channelId?: string | null;
|
acpSource?: TranscriptAcpSource;
|
||||||
};
|
} & TranscriptItemIdentity);
|
||||||
|
|
||||||
export type PromptSection = {
|
export type PromptSection = {
|
||||||
title: string;
|
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 {
|
export function titleCase(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/[_-]+/g, " ")
|
.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 {
|
export function asString(value: unknown): string | null {
|
||||||
return typeof value === "string" ? value : 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)}`;
|
return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sameDayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
const transcriptTimeFormat = new Intl.DateTimeFormat("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
|
hour12: true,
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
const transcriptTitleTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||||
month: "short",
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -97,14 +248,15 @@ const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
|||||||
export function formatTranscriptTime(isoTimestamp: string): string | null {
|
export function formatTranscriptTime(isoTimestamp: string): string | null {
|
||||||
const date = new Date(isoTimestamp);
|
const date = new Date(isoTimestamp);
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
const now = new Date();
|
return transcriptTimeFormat.format(date);
|
||||||
const sameDay =
|
}
|
||||||
date.getFullYear() === now.getFullYear() &&
|
|
||||||
date.getMonth() === now.getMonth() &&
|
export function formatTranscriptTimestampTitle(
|
||||||
date.getDate() === now.getDate();
|
isoTimestamp: string,
|
||||||
return sameDay
|
): string | undefined {
|
||||||
? sameDayTimeFormat.format(date)
|
const date = new Date(isoTimestamp);
|
||||||
: crossDayTimeFormat.format(date);
|
if (Number.isNaN(date.getTime())) return isoTimestamp || undefined;
|
||||||
|
return transcriptTitleTimeFormat.format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
|
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 type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
|
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
|
||||||
@@ -59,7 +62,19 @@ export function AgentSessionThreadPanel({
|
|||||||
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
useEscapeKey(onClose, isOverlay || isSinglePanelView);
|
||||||
|
|
||||||
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
|
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() {
|
async function handleInterruptTurn() {
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
return;
|
return;
|
||||||
@@ -81,7 +96,7 @@ export function AgentSessionThreadPanel({
|
|||||||
|
|
||||||
const agentHeaderActions = (
|
const agentHeaderActions = (
|
||||||
<AuxiliaryPanelHeaderActions>
|
<AuxiliaryPanelHeaderActions>
|
||||||
{isLive && isWorking ? (
|
{isLive ? (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -108,6 +123,30 @@ export function AgentSessionThreadPanel({
|
|||||||
className="min-w-56"
|
className="min-w-56"
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
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
|
<DropdownMenuItem
|
||||||
className="items-start gap-3"
|
className="items-start gap-3"
|
||||||
data-testid="agent-session-stop-turn"
|
data-testid="agent-session-stop-turn"
|
||||||
@@ -118,7 +157,9 @@ export function AgentSessionThreadPanel({
|
|||||||
title={
|
title={
|
||||||
canStopCurrentTurn
|
canStopCurrentTurn
|
||||||
? "Interrupt the current ACP turn without stopping the agent process."
|
? "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" />
|
<Octagon className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
@@ -128,7 +169,9 @@ export function AgentSessionThreadPanel({
|
|||||||
</span>
|
</span>
|
||||||
{!canStopCurrentTurn ? (
|
{!canStopCurrentTurn ? (
|
||||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
<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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
@@ -146,7 +189,9 @@ export function AgentSessionThreadPanel({
|
|||||||
backButtonTestId="agent-session-back"
|
backButtonTestId="agent-session-back"
|
||||||
onBack={onBackToProfile}
|
onBack={onBackToProfile}
|
||||||
>
|
>
|
||||||
<AuxiliaryPanelTitle>Activity</AuxiliaryPanelTitle>
|
<AuxiliaryPanelTitle>
|
||||||
|
{showRawFeed ? "Raw ACP Activity" : "Activity"}
|
||||||
|
</AuxiliaryPanelTitle>
|
||||||
</AuxiliaryPanelHeaderGroup>
|
</AuxiliaryPanelHeaderGroup>
|
||||||
{agentHeaderActions}
|
{agentHeaderActions}
|
||||||
</>
|
</>
|
||||||
@@ -186,8 +231,9 @@ export function AgentSessionThreadPanel({
|
|||||||
: `Mention ${agent.name} in any channel to see its work here.`
|
: `Mention ${agent.name} in any channel to see its work here.`
|
||||||
}
|
}
|
||||||
profiles={profiles}
|
profiles={profiles}
|
||||||
|
rawLayout="exclusive"
|
||||||
showHeader={false}
|
showHeader={false}
|
||||||
showRaw={false}
|
showRaw={showRawFeed}
|
||||||
/>
|
/>
|
||||||
</AuxiliaryPanelBody>
|
</AuxiliaryPanelBody>
|
||||||
</AuxiliaryPanel>
|
</AuxiliaryPanel>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import * as React from "react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
|
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
|
||||||
import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes";
|
import {
|
||||||
import { formatToolTitle } from "@/features/agents/ui/agentSessionToolCatalog";
|
getActivityHeadline,
|
||||||
|
isMeaningfulItem,
|
||||||
|
} from "@/features/agents/ui/agentSessionTranscriptPresentation";
|
||||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||||
import type { ManagedAgent } from "@/shared/api/types";
|
import type { ManagedAgent } from "@/shared/api/types";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
@@ -27,18 +29,6 @@ const HOVER_OPEN_DELAY_MS = 150;
|
|||||||
const HOVER_CLOSE_DELAY_MS = 180;
|
const HOVER_CLOSE_DELAY_MS = 180;
|
||||||
const HEADLINE_ROTATION_MS = 2200;
|
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({
|
export function BotActivityComposerAction({
|
||||||
agents,
|
agents,
|
||||||
channelId = null,
|
channelId = null,
|
||||||
@@ -78,7 +68,11 @@ export function BotActivityComposerAction({
|
|||||||
: transcript;
|
: transcript;
|
||||||
|
|
||||||
for (let i = scopedTranscript.length - 1; i >= 0; i--) {
|
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)) {
|
if (!headline || seen.has(headline)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -185,10 +179,11 @@ export function BotActivityComposerAction({
|
|||||||
"border border-background",
|
"border border-background",
|
||||||
isInline
|
isInline
|
||||||
? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-3xs"
|
? "!h-[18px] !w-[18px] shadow-xs ring-1 ring-primary/25 text-3xs"
|
||||||
: "!h-5 !w-5 text-3xs",
|
: "shrink-0",
|
||||||
)}
|
)}
|
||||||
displayName={agent.name}
|
displayName={agent.name}
|
||||||
key={agent.pubkey}
|
key={agent.pubkey}
|
||||||
|
size="xs"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
@@ -248,8 +243,9 @@ export function BotActivityComposerAction({
|
|||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatarUrl={agentAvatarUrl(agent)}
|
avatarUrl={agentAvatarUrl(agent)}
|
||||||
className="!h-6 !w-6 shrink-0 text-2xs"
|
className="shrink-0"
|
||||||
displayName={agent.name}
|
displayName={agent.name}
|
||||||
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
|
<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" />
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" />
|
||||||
|
|||||||
@@ -285,32 +285,13 @@ export function ChannelScreen({
|
|||||||
: [],
|
: [],
|
||||||
[activeChannel],
|
[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 channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
|
||||||
const channelMembers = channelMembersQuery.data;
|
const channelMembers = channelMembersQuery.data;
|
||||||
const managedAgentsQuery = useManagedAgentsQuery();
|
const managedAgentsQuery = useManagedAgentsQuery();
|
||||||
const managedAgents = managedAgentsQuery.data ?? [];
|
const managedAgents = managedAgentsQuery.data ?? [];
|
||||||
const relayAgentsQuery = useRelayAgentsQuery();
|
const relayAgentsQuery = useRelayAgentsQuery();
|
||||||
const relayAgents = relayAgentsQuery.data ?? [];
|
const relayAgents = relayAgentsQuery.data ?? [];
|
||||||
const agentPubkeys = React.useMemo(() => {
|
const knownAgentPubkeys = React.useMemo(() => {
|
||||||
const pubkeys = new Set<string>();
|
const pubkeys = new Set<string>();
|
||||||
for (const member of channelMembers ?? []) {
|
for (const member of channelMembers ?? []) {
|
||||||
if (member.role === "bot" || member.isAgent) {
|
if (member.role === "bot" || member.isAgent) {
|
||||||
@@ -323,6 +304,31 @@ export function ChannelScreen({
|
|||||||
for (const agent of relayAgents) {
|
for (const agent of relayAgents) {
|
||||||
pubkeys.add(normalizePubkey(agent.pubkey));
|
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(
|
for (const [pubkey, profile] of Object.entries(
|
||||||
messageProfilesQuery.data?.profiles ?? {},
|
messageProfilesQuery.data?.profiles ?? {},
|
||||||
)) {
|
)) {
|
||||||
@@ -331,7 +337,7 @@ export function ChannelScreen({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return pubkeys;
|
return pubkeys;
|
||||||
}, [channelMembers, managedAgents, messageProfilesQuery.data, relayAgents]);
|
}, [knownAgentPubkeys, messageProfilesQuery.data]);
|
||||||
const agentPubkeysPending =
|
const agentPubkeysPending =
|
||||||
activeChannel?.channelType === "dm" &&
|
activeChannel?.channelType === "dm" &&
|
||||||
(channelMembersQuery.isPending ||
|
(channelMembersQuery.isPending ||
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function mergeAgentNamesIntoProfiles(
|
|||||||
relayAgents: RelayAgent[],
|
relayAgents: RelayAgent[],
|
||||||
): UserProfileLookup {
|
): UserProfileLookup {
|
||||||
const merged = { ...profiles };
|
const merged = { ...profiles };
|
||||||
for (const agent of [...relayAgents, ...managedAgents]) {
|
for (const agent of relayAgents) {
|
||||||
const key = normalizePubkey(agent.pubkey);
|
const key = normalizePubkey(agent.pubkey);
|
||||||
merged[key] = {
|
merged[key] = {
|
||||||
...merged[key],
|
...merged[key],
|
||||||
@@ -109,5 +109,15 @@ export function mergeAgentNamesIntoProfiles(
|
|||||||
isAgent: true,
|
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;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,7 @@ import type {
|
|||||||
RuntimeConfigSurface,
|
RuntimeConfigSurface,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
type RawIdentity = {
|
type RawIdentity = { pubkey: string; display_name: string };
|
||||||
pubkey: string;
|
|
||||||
display_name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RawProfile = {
|
type RawProfile = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
@@ -206,6 +203,7 @@ export type RawManagedAgent = {
|
|||||||
max_turn_duration_seconds: number | null;
|
max_turn_duration_seconds: number | null;
|
||||||
parallelism: number;
|
parallelism: number;
|
||||||
system_prompt: string | null;
|
system_prompt: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
persona_out_of_date: boolean;
|
persona_out_of_date: boolean;
|
||||||
@@ -869,6 +867,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
|||||||
maxTurnDurationSeconds: agent.max_turn_duration_seconds,
|
maxTurnDurationSeconds: agent.max_turn_duration_seconds,
|
||||||
parallelism: agent.parallelism,
|
parallelism: agent.parallelism,
|
||||||
systemPrompt: agent.system_prompt,
|
systemPrompt: agent.system_prompt,
|
||||||
|
avatarUrl: agent.avatar_url ?? null,
|
||||||
model: agent.model,
|
model: agent.model,
|
||||||
// Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
|
// Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
|
||||||
provider: agent.provider ?? null,
|
provider: agent.provider ?? null,
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ export type ManagedAgent = {
|
|||||||
maxTurnDurationSeconds: number | null;
|
maxTurnDurationSeconds: number | null;
|
||||||
parallelism: number;
|
parallelism: number;
|
||||||
systemPrompt: string | null;
|
systemPrompt: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
/** LLM inference provider, from the agent's pinned record snapshot. */
|
/** LLM inference provider, from the agent's pinned record snapshot. */
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
|
|||||||
@@ -65,12 +65,14 @@ import {
|
|||||||
} from "./markdown/CodeBlock";
|
} from "./markdown/CodeBlock";
|
||||||
import { FileCard } from "./markdown/FileCard";
|
import { FileCard } from "./markdown/FileCard";
|
||||||
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
|
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
|
||||||
|
import { MarkdownInput } from "./markdown/MarkdownInput";
|
||||||
import { MessageLinkPill } from "./markdown/MessageLinkPill";
|
import { MessageLinkPill } from "./markdown/MessageLinkPill";
|
||||||
import { resolveFileCard } from "./markdownFileCard";
|
import { resolveFileCard } from "./markdownFileCard";
|
||||||
import type {
|
import type {
|
||||||
ImetaEntry,
|
ImetaEntry,
|
||||||
MarkdownProps,
|
MarkdownProps,
|
||||||
MarkdownRuntime,
|
MarkdownRuntime,
|
||||||
|
MarkdownVariant,
|
||||||
} from "./markdown/types";
|
} from "./markdown/types";
|
||||||
import { SpoilerInline } from "./markdown/SpoilerInline";
|
import { SpoilerInline } from "./markdown/SpoilerInline";
|
||||||
import {
|
import {
|
||||||
@@ -1501,12 +1503,19 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMarkdownComponents(
|
function createMarkdownComponents(
|
||||||
|
variant: MarkdownVariant,
|
||||||
runtimeRef: React.RefObject<MarkdownRuntime>,
|
runtimeRef: React.RefObject<MarkdownRuntime>,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
|
mediaInset = false,
|
||||||
): Components {
|
): Components {
|
||||||
const paragraphClassName = "leading-[inherit]";
|
const paragraphClassName =
|
||||||
const listItemClassName = "my-1 [&_p]:inline";
|
variant === "tight"
|
||||||
const listClassName = "space-y-1 pl-6 marker:text-muted-foreground";
|
? "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 {
|
return {
|
||||||
spoiler: ({
|
spoiler: ({
|
||||||
@@ -1699,7 +1708,12 @@ function createMarkdownComponents(
|
|||||||
if (resolvedSrc?.endsWith(".mp4")) {
|
if (resolvedSrc?.endsWith(".mp4")) {
|
||||||
const entry = src ? imetaByUrl?.get(src) : undefined;
|
const entry = src ? imetaByUrl?.get(src) : undefined;
|
||||||
return (
|
return (
|
||||||
<span data-block-media="">
|
<span
|
||||||
|
className={cn(
|
||||||
|
mediaInset && "mx-1.5 block max-w-[calc(100%-0.75rem)]",
|
||||||
|
)}
|
||||||
|
data-block-media=""
|
||||||
|
>
|
||||||
<MarkdownVideoPlayer
|
<MarkdownVideoPlayer
|
||||||
key={src ?? resolvedSrc}
|
key={src ?? resolvedSrc}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
@@ -1722,6 +1736,7 @@ function createMarkdownComponents(
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
input: MarkdownInput,
|
||||||
li: ({ children }) => <li className={listItemClassName}>{children}</li>,
|
li: ({ children }) => <li className={listItemClassName}>{children}</li>,
|
||||||
ol: ({ children }) => (
|
ol: ({ children }) => (
|
||||||
<ol className={cn("list-decimal", listClassName)}>{children}</ol>
|
<ol className={cn("list-decimal", listClassName)}>{children}</ol>
|
||||||
@@ -1910,14 +1925,17 @@ function createMarkdownComponents(
|
|||||||
function MarkdownInner({
|
function MarkdownInner({
|
||||||
channelNames,
|
channelNames,
|
||||||
className,
|
className,
|
||||||
|
compact = false,
|
||||||
content,
|
content,
|
||||||
customEmoji,
|
customEmoji,
|
||||||
imetaByUrl,
|
imetaByUrl,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
agentMentionPubkeysByName,
|
agentMentionPubkeysByName,
|
||||||
|
mediaInset = false,
|
||||||
mentionNames,
|
mentionNames,
|
||||||
mentionPubkeysByName,
|
mentionPubkeysByName,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
tight = false,
|
||||||
videoReviewContext,
|
videoReviewContext,
|
||||||
}: MarkdownProps) {
|
}: MarkdownProps) {
|
||||||
const { channels: rawChannels } = useChannelNavigation();
|
const { channels: rawChannels } = useChannelNavigation();
|
||||||
@@ -1958,9 +1976,16 @@ function MarkdownInner({
|
|||||||
onOpenMessageLink,
|
onOpenMessageLink,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const variant: MarkdownVariant = tight
|
||||||
|
? "tight"
|
||||||
|
: compact
|
||||||
|
? "compact"
|
||||||
|
: "default";
|
||||||
|
|
||||||
const components = React.useMemo(
|
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
|
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
|
||||||
@@ -2028,6 +2053,13 @@ function MarkdownInner({
|
|||||||
"[&>*+hr]:mt-4 [&>hr+*]:mt-4",
|
"[&>*+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",
|
"[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5",
|
||||||
].join(" "),
|
].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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -2053,8 +2085,11 @@ export const Markdown = React.memo(
|
|||||||
(prev, next) =>
|
(prev, next) =>
|
||||||
prev.content === next.content &&
|
prev.content === next.content &&
|
||||||
prev.className === next.className &&
|
prev.className === next.className &&
|
||||||
|
prev.compact === next.compact &&
|
||||||
prev.customEmoji === next.customEmoji &&
|
prev.customEmoji === next.customEmoji &&
|
||||||
prev.interactive === next.interactive &&
|
prev.interactive === next.interactive &&
|
||||||
|
prev.mediaInset === next.mediaInset &&
|
||||||
|
prev.tight === next.tight &&
|
||||||
prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName &&
|
prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName &&
|
||||||
prev.mentionPubkeysByName === next.mentionPubkeysByName &&
|
prev.mentionPubkeysByName === next.mentionPubkeysByName &&
|
||||||
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&
|
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 = {
|
export type MarkdownProps = {
|
||||||
channelNames?: string[];
|
channelNames?: string[];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
content: string;
|
content: string;
|
||||||
customEmoji?: CustomEmoji[];
|
customEmoji?: CustomEmoji[];
|
||||||
imetaByUrl?: ImetaLookup;
|
imetaByUrl?: ImetaLookup;
|
||||||
@@ -42,6 +43,10 @@ export type MarkdownProps = {
|
|||||||
agentMentionPubkeysByName?: Record<string, string>;
|
agentMentionPubkeysByName?: Record<string, string>;
|
||||||
mentionNames?: string[];
|
mentionNames?: string[];
|
||||||
mentionPubkeysByName?: Record<string, string>;
|
mentionPubkeysByName?: Record<string, string>;
|
||||||
|
mediaInset?: boolean;
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
|
tight?: boolean;
|
||||||
videoReviewContext?: VideoReviewContext;
|
videoReviewContext?: VideoReviewContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MarkdownVariant = "default" | "compact" | "tight";
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ const toggleVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-transparent",
|
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: {
|
size: {
|
||||||
default: "h-9 px-3 min-w-9",
|
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",
|
sm: "h-8 px-2 min-w-8",
|
||||||
lg: "h-10 px-3 min-w-10",
|
lg: "h-10 px-3 min-w-10",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type MockCommandAvailability = {
|
|||||||
type MockManagedAgentSeed = {
|
type MockManagedAgentSeed = {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
personaId?: string | null;
|
personaId?: string | null;
|
||||||
status?: RawManagedAgent["status"];
|
status?: RawManagedAgent["status"];
|
||||||
channelNames?: string[];
|
channelNames?: string[];
|
||||||
@@ -389,6 +390,7 @@ type RawManagedAgent = {
|
|||||||
max_turn_duration_seconds: number | null;
|
max_turn_duration_seconds: number | null;
|
||||||
parallelism: number;
|
parallelism: number;
|
||||||
system_prompt: string | null;
|
system_prompt: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
env_vars?: Record<string, string>;
|
env_vars?: Record<string, string>;
|
||||||
status: "running" | "stopped" | "deployed" | "not_deployed";
|
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,
|
max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null,
|
||||||
parallelism: agent.parallelism,
|
parallelism: agent.parallelism,
|
||||||
system_prompt: agent.system_prompt,
|
system_prompt: agent.system_prompt,
|
||||||
|
avatar_url: agent.avatar_url ?? null,
|
||||||
model: agent.model,
|
model: agent.model,
|
||||||
env_vars: { ...(agent.env_vars ?? {}) },
|
env_vars: { ...(agent.env_vars ?? {}) },
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
@@ -1450,6 +1453,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
|
|||||||
max_turn_duration_seconds: null,
|
max_turn_duration_seconds: null,
|
||||||
parallelism: 1,
|
parallelism: 1,
|
||||||
system_prompt: null,
|
system_prompt: null,
|
||||||
|
avatar_url: seed.avatarUrl ?? null,
|
||||||
model: null,
|
model: null,
|
||||||
env_vars: {},
|
env_vars: {},
|
||||||
status,
|
status,
|
||||||
@@ -5670,6 +5674,7 @@ async function handleCreateManagedAgent(
|
|||||||
max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null,
|
max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null,
|
||||||
parallelism: args.input.parallelism ?? 1,
|
parallelism: args.input.parallelism ?? 1,
|
||||||
system_prompt: args.input.systemPrompt?.trim() || null,
|
system_prompt: args.input.systemPrompt?.trim() || null,
|
||||||
|
avatar_url: avatarUrl,
|
||||||
model: args.input.model?.trim() || null,
|
model: args.input.model?.trim() || null,
|
||||||
env_vars: { ...(args.input.envVars ?? {}) },
|
env_vars: { ...(args.input.envVars ?? {}) },
|
||||||
status: args.input.spawnAfterCreate ? "running" : "stopped",
|
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(
|
await expect(page.getByTestId("agent-session-thread-panel")).toContainText(
|
||||||
"alice",
|
"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 expect(page.getByTestId("message-typing-indicator")).toHaveCount(0);
|
||||||
|
|
||||||
await page.evaluate((pubkey) => {
|
await page.evaluate((pubkey) => {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const CASEY_PROFILE_PUBKEY =
|
|||||||
"1111111111111111111111111111111111111111111111111111111111111111";
|
"1111111111111111111111111111111111111111111111111111111111111111";
|
||||||
const PROFILE_ONLY_AGENT_PUBKEY =
|
const PROFILE_ONLY_AGENT_PUBKEY =
|
||||||
"8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
|
"8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67";
|
||||||
|
const UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY =
|
||||||
|
"7777777777777777777777777777777777777777777777777777777777777777";
|
||||||
const SYSTEM_MESSAGE_KIND = 40099;
|
const SYSTEM_MESSAGE_KIND = 40099;
|
||||||
|
|
||||||
/** Locator scoped to the mention autocomplete dropdown inside the composer. */
|
/** 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 ({
|
test("wave attachment huddle waits for placeholder profile-only bot data", async ({
|
||||||
page,
|
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.goto("/");
|
||||||
await page.getByTestId("channel-general").click();
|
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,
|
kind: SYSTEM_MESSAGE_KIND,
|
||||||
targetPubkey: PROFILE_ONLY_AGENT_PUBKEY,
|
targetPubkey: UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitForTimelineSettled(page);
|
await waitForTimelineSettled(page);
|
||||||
@@ -1492,12 +1504,9 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
|
|||||||
const joinedRow = page
|
const joinedRow = page
|
||||||
.getByTestId("system-message-row")
|
.getByTestId("system-message-row")
|
||||||
.filter({ hasText: "joined the channel" });
|
.filter({ hasText: "joined the channel" });
|
||||||
const agentChip = joinedRow.locator(
|
const agentChip = joinedRow.locator("[data-mention]", {
|
||||||
"[data-mention].agent-mention-highlight",
|
hasText: "77777777…7777",
|
||||||
{
|
});
|
||||||
hasText: "mira",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await expect(agentChip).toBeVisible({ timeout: 5_000 });
|
await expect(agentChip).toBeVisible({ timeout: 5_000 });
|
||||||
await agentChip.hover();
|
await agentChip.hover();
|
||||||
|
|
||||||
@@ -1506,7 +1515,9 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
|
|||||||
);
|
);
|
||||||
await expect(profilePopover).toBeVisible();
|
await expect(profilePopover).toBeVisible();
|
||||||
await profilePopover
|
await profilePopover
|
||||||
.getByTestId(`user-profile-popover-wave-${PROFILE_ONLY_AGENT_PUBKEY}`)
|
.getByTestId(
|
||||||
|
`user-profile-popover-wave-${UNSEEDED_PROFILE_ONLY_AGENT_PUBKEY}`,
|
||||||
|
)
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
const startHuddleButton = page
|
const startHuddleButton = page
|
||||||
@@ -1518,7 +1529,7 @@ test("wave attachment huddle waits for placeholder profile-only bot data", async
|
|||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => readStartHuddleMemberPubkeys(page))
|
.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 ({
|
test("wave attachment huddle waits for delayed bot DM pubkey", async ({
|
||||||
|
|||||||
Reference in New Issue
Block a user