From 5db3f0890efcceb04def4d81c4c51bcdb3cc2f9f Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 3 May 2026 11:32:00 -0600 Subject: [PATCH] feat(desktop): add timestamps and avatar chips to ACP transcript (#463) Co-authored-by: Claude Opus 4.6 --- .../agents/ui/AgentSessionToolItem.tsx | 92 +++++++++++++++++-- .../agents/ui/AgentSessionTranscriptList.tsx | 42 ++++++++- .../agents/ui/agentSessionTranscript.ts | 8 ++ .../ui/agentSessionTranscriptHelpers.ts | 12 +-- .../features/agents/ui/agentSessionTypes.ts | 2 + .../features/agents/ui/agentSessionUtils.ts | 52 +++++++++++ 6 files changed, 190 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 6d4205ea1..639cb83fd 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -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} ) : null} + @@ -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; +}) { + 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 ( + + + + {time} + {duration ? ` ยท ${duration}` : null} + + + {fullDateTime} + + ); +} + 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} {action.label} {action.value} @@ -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} {action.label} {action.value} @@ -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; channelId: string | null; channels: Channel[]; openChannel: (messageId?: string) => void; + profiles: Record | 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: ( + + ), + 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`, }; } diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index a776e47f0..ddc5901a5 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -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({ {agentName} + ) : null}
) : ( -

{text}

+ <> +

{text}

+ + )}
@@ -120,6 +126,7 @@ function ThoughtItem({ {item.title} +
@@ -142,6 +149,7 @@ function MetadataItem({ {item.sections.length} section{item.sections.length === 1 ? "" : "s"} +
@@ -173,12 +181,42 @@ function LifecycleItem({ return (
{item.title} {item.text ? - {item.text} : null} +
); } + +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 ( + + + + {formatted} + + + {fullDateTime} + + ); +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 5f65ec474..e10af80ca 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -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); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 72c3c4a80..74f86e239 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -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 { 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 { diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index b4b8c9f2a..678ca3d33 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -61,6 +61,8 @@ export type TranscriptItem = result: string; isError: boolean; timestamp: string; + startedAt: string; + completedAt: string | null; }; export type PromptSection = { diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts index 5efa9d001..ae3a147ab 100644 --- a/desktop/src/features/agents/ui/agentSessionUtils.ts +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -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`; +}