mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add timestamps and avatar chips to ACP transcript (#463)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,13 @@ import * as React from "react";
|
||||
import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
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,
|
||||
@@ -14,6 +18,8 @@ import {
|
||||
import {
|
||||
asRecord,
|
||||
formatCodeValue,
|
||||
formatDuration,
|
||||
formatTranscriptTime,
|
||||
getResultArray,
|
||||
getToolString,
|
||||
getToolStringList,
|
||||
@@ -74,6 +80,7 @@ export function ToolItem({
|
||||
{status.label}
|
||||
</span>
|
||||
) : null}
|
||||
<ToolTimestamp item={item} />
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
|
||||
@@ -163,6 +170,44 @@ 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({
|
||||
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-[11px] text-muted-foreground/60">
|
||||
{time}
|
||||
{duration ? ` · ${duration}` : null}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{fullDateTime}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SproutToolInlineAction({
|
||||
args,
|
||||
result,
|
||||
@@ -180,6 +225,14 @@ function SproutToolInlineAction({
|
||||
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;
|
||||
@@ -194,9 +247,10 @@ function SproutToolInlineAction({
|
||||
channelId,
|
||||
channels,
|
||||
openChannel,
|
||||
profiles,
|
||||
resultValue,
|
||||
}),
|
||||
[args, channelId, channels, openChannel, resultValue],
|
||||
[args, channelId, channels, openChannel, profiles, resultValue],
|
||||
);
|
||||
|
||||
if (!action) {
|
||||
@@ -215,6 +269,7 @@ function SproutToolInlineAction({
|
||||
title={action.title}
|
||||
type="button"
|
||||
>
|
||||
{action.avatar}
|
||||
<span className="shrink-0">{action.label}</span>
|
||||
<span className="truncate">{action.value}</span>
|
||||
<ArrowUpRight className="h-3 w-3 shrink-0" />
|
||||
@@ -227,6 +282,7 @@ function SproutToolInlineAction({
|
||||
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-[11px] 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>
|
||||
@@ -234,6 +290,7 @@ function SproutToolInlineAction({
|
||||
}
|
||||
|
||||
type SproutToolInlineActionModel = {
|
||||
avatar?: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
title: string;
|
||||
@@ -245,12 +302,14 @@ function getSproutToolInlineAction({
|
||||
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;
|
||||
}): SproutToolInlineActionModel | null {
|
||||
const resultRecord = asRecord(resultValue);
|
||||
@@ -299,13 +358,30 @@ function getSproutToolInlineAction({
|
||||
|
||||
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: pubkeys.length === 1 ? "pubkey" : "users",
|
||||
title: pubkeys.join(", "),
|
||||
value:
|
||||
pubkeys.length === 1
|
||||
? shortenMiddle(pubkeys[0], 24)
|
||||
: `${pubkeys.length} pubkeys`,
|
||||
label: "users",
|
||||
title: pubkeys
|
||||
.map((pk) => resolveUserLabel({ pubkey: pk, profiles }))
|
||||
.join(", "),
|
||||
value: `${pubkeys.length} users`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
import { ToolItem } from "./AgentSessionToolItem";
|
||||
import { formatTranscriptTime } from "./agentSessionUtils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
export function AgentSessionTranscriptList({
|
||||
agentName,
|
||||
@@ -91,6 +93,7 @@ function MessageItem({
|
||||
<Bot className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="font-normal text-foreground">{agentName}</span>
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
@@ -102,7 +105,10 @@ function MessageItem({
|
||||
{isAssistant ? (
|
||||
<Markdown compact content={text || " "} />
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words">{text}</p>
|
||||
<>
|
||||
<p className="whitespace-pre-wrap break-words">{text}</p>
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,6 +126,7 @@ function ThoughtItem({
|
||||
<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-3.5 w-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="py-2 pl-5 text-sm leading-6 text-muted-foreground">
|
||||
@@ -142,6 +149,7 @@ function MetadataItem({
|
||||
<span className="shrink-0 text-xs">
|
||||
{item.sections.length} section{item.sections.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<TranscriptTimestamp timestamp={item.timestamp} />
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="space-y-3 py-2 pl-5">
|
||||
@@ -173,12 +181,42 @@ function LifecycleItem({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-2 text-center text-xs",
|
||||
"flex items-center justify-center gap-1.5 px-4 py-2 text-center 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-[11px] text-muted-foreground/60">
|
||||
{formatted}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{fullDateTime}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,6 +147,12 @@ export function buildTranscript(events: ObserverEvent[]): TranscriptItem[] {
|
||||
existing.args = Object.keys(args).length > 0 ? args : existing.args;
|
||||
if (result) existing.result = result;
|
||||
existing.isError = isError || existing.isError;
|
||||
if (
|
||||
(status === "completed" || status === "failed") &&
|
||||
existing.completedAt == null
|
||||
) {
|
||||
existing.completedAt = timestamp;
|
||||
}
|
||||
return;
|
||||
}
|
||||
sealOpenMessages();
|
||||
@@ -161,6 +167,8 @@ export function buildTranscript(events: ObserverEvent[]): TranscriptItem[] {
|
||||
result,
|
||||
isError,
|
||||
timestamp,
|
||||
startedAt: timestamp,
|
||||
completedAt: null,
|
||||
};
|
||||
items.push(item);
|
||||
itemsById.set(id, item);
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
isGenericToolTitle,
|
||||
normalizeToolName,
|
||||
} from "./agentSessionToolCatalog";
|
||||
import { asRecord, asString, shorten, titleCase } from "./agentSessionUtils";
|
||||
import { asRecord, asString, titleCase } from "./agentSessionUtils";
|
||||
|
||||
export function extractPromptText(payload: Record<string, unknown>): string {
|
||||
const params = asRecord(payload.params);
|
||||
@@ -194,18 +194,14 @@ export function describeTurnStarted(payload: unknown): string {
|
||||
)
|
||||
: [];
|
||||
return ids.length > 0
|
||||
? `Triggered by ${ids.map(shorten).join(", ")}.`
|
||||
: "Heartbeat or internal turn.";
|
||||
? `Triggered by ${ids.length === 1 ? "1 event" : `${ids.length} events`}.`
|
||||
: "";
|
||||
}
|
||||
|
||||
export function describeSessionResolved(payload: unknown): string {
|
||||
const record = asRecord(payload);
|
||||
const sessionId = asString(record.sessionId);
|
||||
const isNewSession = record.isNewSession === true;
|
||||
if (!sessionId) {
|
||||
return "Using existing ACP session.";
|
||||
}
|
||||
return `${isNewSession ? "Created" : "Using"} session ${shorten(sessionId)}.`;
|
||||
return isNewSession ? "New session created." : "";
|
||||
}
|
||||
|
||||
export function describeRawEvent(event: ObserverEvent): string {
|
||||
|
||||
@@ -61,6 +61,8 @@ export type TranscriptItem =
|
||||
result: string;
|
||||
isError: boolean;
|
||||
timestamp: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
export type PromptSection = {
|
||||
|
||||
@@ -79,3 +79,55 @@ export function shortenMiddle(value: string, maxLength: number) {
|
||||
const edgeLength = Math.max(4, Math.floor((maxLength - 3) / 2));
|
||||
return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`;
|
||||
}
|
||||
|
||||
const sameDayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
export function formatTranscriptTime(isoTimestamp: string): string | null {
|
||||
const date = new Date(isoTimestamp);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const now = new Date();
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
return sameDay
|
||||
? sameDayTimeFormat.format(date)
|
||||
: crossDayTimeFormat.format(date);
|
||||
}
|
||||
|
||||
export function formatDuration(
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
): string | null {
|
||||
if (!startIso || !endIso) return null;
|
||||
const start = new Date(startIso).getTime();
|
||||
const end = new Date(endIso).getTime();
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
||||
const ms = end - start;
|
||||
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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user