mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(agents): keep live activity styling in the header
- Remove row-level Live badges and active highlight styling from assistant transcript messages, thought rows, and tool rows - Stop passing transcript active item state through AgentSessionTranscriptList once row-level activity styling is no longer rendered - Preserve the panel header Live indicator as the single source of current agent activity state
This commit is contained in:
@@ -1,60 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { ArrowUpRight, ChevronDown, CircleDot, Wrench } from "lucide-react";
|
||||
import { ChevronDown } 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 { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
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 {
|
||||
buildCompactToolSummary,
|
||||
isCompactDeveloperTool,
|
||||
} from "./agentSessionToolSummary";
|
||||
import {
|
||||
asRecord,
|
||||
formatCodeValue,
|
||||
formatDuration,
|
||||
formatTranscriptTime,
|
||||
getResultArray,
|
||||
getToolString,
|
||||
getToolStringList,
|
||||
shortenMiddle,
|
||||
} from "./agentSessionUtils";
|
||||
import { getBuzzToolInfo } from "./agentSessionToolCatalog";
|
||||
import { buildCompactToolSummary } from "./agentSessionToolSummary";
|
||||
import { asRecord, formatCodeValue, formatDuration } from "./agentSessionUtils";
|
||||
|
||||
export function ToolItem({
|
||||
compact = false,
|
||||
isActive = false,
|
||||
item,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
isActive?: boolean;
|
||||
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 useCompactSummary = isCompactDeveloperTool(item);
|
||||
const compactSummary = useCompactSummary
|
||||
? buildCompactToolSummary(item)
|
||||
: null;
|
||||
const compactSummary = buildCompactToolSummary(item);
|
||||
const duration = getToolDuration(item);
|
||||
const handleToggle = React.useCallback(
|
||||
(event: React.SyntheticEvent<HTMLDetailsElement>) => {
|
||||
@@ -65,12 +32,7 @@ export function ToolItem({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose w-full",
|
||||
compact ? "px-0" : "px-1",
|
||||
isActive &&
|
||||
"rounded-lg border border-primary/15 bg-primary/3 px-2 py-1",
|
||||
)}
|
||||
className={cn("not-prose w-full", compact ? "px-0" : "px-1")}
|
||||
data-testid="transcript-tool-item"
|
||||
>
|
||||
<details
|
||||
@@ -80,59 +42,16 @@ export function ToolItem({
|
||||
>
|
||||
<summary
|
||||
className={cn(
|
||||
"inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px",
|
||||
useCompactSummary && "text-muted-foreground",
|
||||
"inline-flex max-w-full cursor-pointer list-none items-center gap-5 py-px",
|
||||
compactSummaryTone(),
|
||||
)}
|
||||
>
|
||||
{compactSummary ? (
|
||||
<CompactToolSummaryRow
|
||||
duration={duration}
|
||||
preview={compactSummary.preview}
|
||||
thumbnailSrc={compactSummary.thumbnailSrc}
|
||||
label={compactSummary.label}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{ToolIcon ? (
|
||||
<ToolIcon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
buzzTool || isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{toolTitle}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Badge
|
||||
className="h-4 gap-0.5 px-1 text-xs font-normal"
|
||||
variant="default"
|
||||
>
|
||||
<CircleDot className="h-2 w-2" />
|
||||
Live
|
||||
</Badge>
|
||||
) : null}
|
||||
{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} duration={duration} />
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</>
|
||||
)}
|
||||
<CompactToolSummaryRow
|
||||
duration={duration}
|
||||
preview={compactSummary.preview}
|
||||
thumbnailSrc={compactSummary.thumbnailSrc}
|
||||
label={compactSummary.label}
|
||||
/>
|
||||
</summary>
|
||||
|
||||
<ToolDetailBlocks
|
||||
@@ -141,7 +60,7 @@ export function ToolItem({
|
||||
hasArgs={hasArgs}
|
||||
hasResult={hasResult}
|
||||
imagePreview={
|
||||
compactSummary?.kind === "view_image" && isExpanded
|
||||
compactSummary.kind === "view_image" && isExpanded
|
||||
? {
|
||||
src: compactSummary.thumbnailSrc,
|
||||
title: compactSummary.preview,
|
||||
@@ -156,6 +75,10 @@ export function ToolItem({
|
||||
);
|
||||
}
|
||||
|
||||
function compactSummaryTone() {
|
||||
return "text-muted-foreground/60 group-open:text-muted-foreground";
|
||||
}
|
||||
|
||||
function CompactToolSummaryRow({
|
||||
duration,
|
||||
label,
|
||||
@@ -168,6 +91,7 @@ function CompactToolSummaryRow({
|
||||
thumbnailSrc: string | null;
|
||||
}) {
|
||||
const [thumbnailFailed, setThumbnailFailed] = React.useState(false);
|
||||
const mutedTone = compactSummaryTone();
|
||||
const resolvedThumbnail = React.useMemo(() => {
|
||||
if (!thumbnailSrc || thumbnailFailed) return null;
|
||||
return resolveImageSrc(thumbnailSrc);
|
||||
@@ -175,7 +99,9 @@ function CompactToolSummaryRow({
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="shrink-0 text-sm font-semibold">{label}</span>
|
||||
<span className={cn("shrink-0 text-sm font-semibold", mutedTone)}>
|
||||
{label}
|
||||
</span>
|
||||
{resolvedThumbnail ? (
|
||||
<img
|
||||
alt=""
|
||||
@@ -188,18 +114,21 @@ function CompactToolSummaryRow({
|
||||
/>
|
||||
) : preview ? (
|
||||
<span
|
||||
className="min-w-0 max-w-48 truncate text-sm text-muted-foreground/70"
|
||||
className={cn("min-w-0 max-w-48 truncate text-sm", mutedTone)}
|
||||
title={preview}
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
) : null}
|
||||
{duration ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground/70">
|
||||
{duration}
|
||||
</span>
|
||||
<span className={cn("shrink-0 text-xs", mutedTone)}>{duration}</span>
|
||||
) : null}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform group-open:rotate-180",
|
||||
mutedTone,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -433,239 +362,6 @@ function ToolCodeBlock({
|
||||
);
|
||||
}
|
||||
|
||||
const toolFullDateTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
function ToolTimestamp({
|
||||
duration,
|
||||
item,
|
||||
}: {
|
||||
duration: string | null;
|
||||
item: Extract<TranscriptItem, { type: "tool" }>;
|
||||
}) {
|
||||
const time = formatTranscriptTime(item.timestamp);
|
||||
if (!time) return 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-xs 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-56 shrink min-w-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/5 px-1.5 py-0.5 text-xs 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-56 shrink min-w-0 items-center gap-1 rounded-full border border-border/60 bg-muted/40 px-1.5 py-0.5 text-xs 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;
|
||||
@@ -682,8 +378,3 @@ function parseToolResultValue(result: string): unknown {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getChannelChipLabel(channels: Channel[], channelId: string) {
|
||||
const channel = channels.find((candidate) => candidate.id === channelId);
|
||||
return channel ? `#${channel.name}` : `#${shortenMiddle(channelId, 22)}`;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Toggle } from "@/shared/ui/toggle";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
@@ -29,12 +28,35 @@ import {
|
||||
type TranscriptDisplayBlock,
|
||||
type TranscriptTurnSegment,
|
||||
} from "./agentSessionTranscriptGrouping";
|
||||
import { buildTranscriptPresentation } from "./agentSessionTranscriptPresentation";
|
||||
import { formatTranscriptTime } from "./agentSessionUtils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
/** Dev-only: surface the observer wire label that produced each transcript row. */
|
||||
const SHOW_TRANSCRIPT_ACP_SOURCE = import.meta.env.DEV;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
type AgentTranscriptIdentityProps = {
|
||||
agentAvatarUrl: string | null;
|
||||
@@ -48,7 +70,6 @@ export function AgentSessionTranscriptList({
|
||||
agentPubkey,
|
||||
compact = false,
|
||||
emptyDescription,
|
||||
isWorking = false,
|
||||
items,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
@@ -58,10 +79,6 @@ export function AgentSessionTranscriptList({
|
||||
items: TranscriptItem[];
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const presentation = React.useMemo(
|
||||
() => buildTranscriptPresentation(items, isWorking),
|
||||
[items, isWorking],
|
||||
);
|
||||
const displayBlocks = React.useMemo(
|
||||
() => buildTranscriptDisplayBlocks(items),
|
||||
[items],
|
||||
@@ -92,7 +109,6 @@ export function AgentSessionTranscriptList({
|
||||
>
|
||||
{displayBlocks.map((block) => (
|
||||
<TranscriptDisplayBlockView
|
||||
activeItemIds={presentation.activeItemIds}
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
@@ -127,7 +143,6 @@ function getDisplayBlockKey(block: TranscriptDisplayBlock) {
|
||||
}
|
||||
|
||||
function TranscriptDisplayBlockView({
|
||||
activeItemIds,
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
@@ -135,7 +150,6 @@ function TranscriptDisplayBlockView({
|
||||
compact,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
activeItemIds: ReadonlySet<string>;
|
||||
block: TranscriptDisplayBlock;
|
||||
compact: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
@@ -143,7 +157,6 @@ function TranscriptDisplayBlockView({
|
||||
if (block.kind === "single") {
|
||||
return (
|
||||
<TranscriptItemRow
|
||||
activeItemIds={activeItemIds}
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
@@ -162,7 +175,6 @@ function TranscriptDisplayBlockView({
|
||||
>
|
||||
{block.segments.map((segment) => (
|
||||
<TranscriptTurnSegmentView
|
||||
activeItemIds={activeItemIds}
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
@@ -187,7 +199,6 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
|
||||
}
|
||||
|
||||
function TranscriptTurnSegmentView({
|
||||
activeItemIds,
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
@@ -195,7 +206,6 @@ function TranscriptTurnSegmentView({
|
||||
profiles,
|
||||
segment,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
activeItemIds: ReadonlySet<string>;
|
||||
compact: boolean;
|
||||
profiles?: UserProfileLookup;
|
||||
segment: TranscriptTurnSegment;
|
||||
@@ -218,7 +228,6 @@ function TranscriptTurnSegmentView({
|
||||
|
||||
return (
|
||||
<TranscriptItemRow
|
||||
activeItemIds={activeItemIds}
|
||||
agentAvatarUrl={agentAvatarUrl}
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
@@ -313,7 +322,7 @@ function PromptUserMessage({
|
||||
>
|
||||
<p className="whitespace-pre-wrap wrap-break-word">{text}</p>
|
||||
{contextOpen && context ? (
|
||||
<PromptContextSections sections={context.sections} />
|
||||
<PromptContextSections sections={context.sections} setup={setup} />
|
||||
) : null}
|
||||
</div>
|
||||
<TurnSetupFooter
|
||||
@@ -328,12 +337,19 @@ function PromptUserMessage({
|
||||
);
|
||||
}
|
||||
|
||||
function PromptContextSections({ sections }: { sections: PromptSection[] }) {
|
||||
function PromptContextSections({
|
||||
sections,
|
||||
setup,
|
||||
}: {
|
||||
sections: PromptSection[];
|
||||
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="mt-2 space-y-2 border-t border-border/40 pt-2"
|
||||
data-testid="transcript-prompt-context-sections"
|
||||
>
|
||||
<PromptSetupSummary items={setup} />
|
||||
{sections.map((section) => (
|
||||
<details
|
||||
className="group/section"
|
||||
@@ -352,6 +368,29 @@ function PromptContextSections({ sections }: { sections: PromptSection[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PromptSetupSummary({
|
||||
items,
|
||||
}: {
|
||||
items: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
}) {
|
||||
const label = formatTurnSetupLabel(items);
|
||||
const detail = turnSetupDetail(items);
|
||||
const setupText = [label, detail].filter(Boolean).join(" · ");
|
||||
|
||||
if (!setupText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
className="text-xs leading-5 text-muted-foreground"
|
||||
data-testid="transcript-prompt-setup-summary"
|
||||
>
|
||||
{setupText}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnSetupFooter({
|
||||
context = null,
|
||||
contextOpen = false,
|
||||
@@ -375,12 +414,35 @@ function TurnSetupFooter({
|
||||
return <TranscriptTimestamp timestamp={timestamp} />;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
{showSetup ? <CheckCheck aria-hidden="true" /> : null}
|
||||
Context
|
||||
</Toggle>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-muted-foreground/80"
|
||||
data-testid="transcript-turn-setup"
|
||||
>
|
||||
{showSetup ? (
|
||||
{showContext && showSetup ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{contextToggle}</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!showContext && showSetup ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -396,25 +458,13 @@ function TurnSetupFooter({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showContext ? (
|
||||
<Toggle
|
||||
aria-label={`${contextOpen ? "Hide" : "Show"} prompt context`}
|
||||
data-testid="transcript-prompt-context-toggle"
|
||||
onPressedChange={onContextOpenChange}
|
||||
pressed={contextOpen}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
Context
|
||||
</Toggle>
|
||||
) : null}
|
||||
{showContext && !showSetup ? contextToggle : null}
|
||||
<TranscriptTimestamp timestamp={timestamp} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptItemRow({
|
||||
activeItemIds,
|
||||
agentAvatarUrl,
|
||||
agentName,
|
||||
agentPubkey,
|
||||
@@ -422,7 +472,6 @@ function TranscriptItemRow({
|
||||
item,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
activeItemIds: ReadonlySet<string>;
|
||||
compact: boolean;
|
||||
item: TranscriptItem;
|
||||
profiles?: UserProfileLookup;
|
||||
@@ -444,7 +493,6 @@ function TranscriptItemRow({
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
compact={compact}
|
||||
isActive={activeItemIds.has(item.id)}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
@@ -486,12 +534,10 @@ const TranscriptItemView = React.memo(function TranscriptItemView({
|
||||
agentName,
|
||||
agentPubkey,
|
||||
compact,
|
||||
isActive,
|
||||
item,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
compact: boolean;
|
||||
isActive: boolean;
|
||||
item: TranscriptItem;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
@@ -502,17 +548,16 @@ const TranscriptItemView = React.memo(function TranscriptItemView({
|
||||
agentName={agentName}
|
||||
agentPubkey={agentPubkey}
|
||||
compact={compact}
|
||||
isActive={isActive}
|
||||
item={item}
|
||||
profiles={profiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.type === "tool") {
|
||||
return <ToolItem compact={compact} isActive={isActive} item={item} />;
|
||||
return <ToolItem compact={compact} item={item} />;
|
||||
}
|
||||
if (item.type === "thought") {
|
||||
return <ThoughtItem compact={compact} isActive={isActive} item={item} />;
|
||||
return <ThoughtItem compact={compact} item={item} />;
|
||||
}
|
||||
if (item.type === "metadata") {
|
||||
return <MetadataItem compact={compact} item={item} />;
|
||||
@@ -525,12 +570,10 @@ function MessageItem({
|
||||
agentName,
|
||||
agentPubkey,
|
||||
compact,
|
||||
isActive,
|
||||
item,
|
||||
profiles,
|
||||
}: AgentTranscriptIdentityProps & {
|
||||
compact: boolean;
|
||||
isActive: boolean;
|
||||
item: Extract<TranscriptItem, { type: "message" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
@@ -561,9 +604,6 @@ function MessageItem({
|
||||
"flex animate-in fade-in duration-200 motion-reduce:animate-none",
|
||||
isAssistant ? "flex-row" : "flex-row items-start justify-end",
|
||||
compact ? "px-0 py-0.5" : "px-1 py-1",
|
||||
isAssistant &&
|
||||
isActive &&
|
||||
"rounded-lg border border-primary/15 bg-primary/3 px-2 py-1.5",
|
||||
)}
|
||||
data-role={isAssistant ? "assistant-message" : "user-message"}
|
||||
data-testid={
|
||||
@@ -596,15 +636,6 @@ function MessageItem({
|
||||
<span className="text-xs font-semibold text-foreground">
|
||||
{assistantLabel}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Badge
|
||||
className="h-4 gap-0.5 px-1 text-xs font-normal"
|
||||
variant="default"
|
||||
>
|
||||
<CircleDot className="h-2 w-2" />
|
||||
Live
|
||||
</Badge>
|
||||
) : null}
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -630,11 +661,9 @@ function MessageItem({
|
||||
|
||||
function ThoughtItem({
|
||||
compact,
|
||||
isActive,
|
||||
item,
|
||||
}: {
|
||||
compact: boolean;
|
||||
isActive: boolean;
|
||||
item: Extract<TranscriptItem, { type: "thought" }>;
|
||||
}) {
|
||||
return (
|
||||
@@ -642,22 +671,12 @@ function ThoughtItem({
|
||||
className={cn(
|
||||
"group not-prose w-full rounded-md border border-transparent",
|
||||
compact ? "px-0" : "px-1",
|
||||
isActive && "border-primary/15 bg-primary/3 px-2 py-1",
|
||||
)}
|
||||
data-testid="transcript-thought-item"
|
||||
>
|
||||
<summary className="inline-flex max-w-full cursor-pointer list-none items-center gap-1.5 py-px text-muted-foreground">
|
||||
<Brain className={cn("h-4 w-4", isActive && "text-primary")} />
|
||||
<Brain className="h-4 w-4" />
|
||||
<span className="truncate text-sm font-medium">{item.title}</span>
|
||||
{isActive ? (
|
||||
<Badge
|
||||
className="h-4 gap-0.5 px-1 text-xs font-normal"
|
||||
variant="default"
|
||||
>
|
||||
<CircleDot className="h-2 w-2" />
|
||||
Live
|
||||
</Badge>
|
||||
) : null}
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
|
||||
Reference in New Issue
Block a user