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:
Wes
2026-05-03 17:32:00 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 0762eb3ce3
commit 5db3f0890e
6 changed files with 190 additions and 18 deletions
@@ -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`;
}