feat(desktop): show activity timestamps on demand (#1506)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-07-04 08:40:49 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent f35aeb7986
commit c7a8b0babb
6 changed files with 272 additions and 39 deletions
@@ -27,6 +27,7 @@ import {
useAgentSessionTranscriptVariant,
} from "./agentSessionTranscriptContext";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
import {
ActivityRow,
@@ -47,6 +48,7 @@ import {
type TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
import { buildCompactToolSummary } from "./agentSessionToolSummary";
import { shouldShowTranscriptRowTimestamp } from "./agentSessionTranscriptPresentation";
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
import { hasFileEditLineDiff } from "./FileEditDiffView";
import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";
@@ -462,44 +464,55 @@ function SameKindSummaryItem({
const expandsToToolItems = summary.items.every(
(item) => item.type === "tool",
);
const variant = useAgentSessionTranscriptVariant();
const timestampsEnabled = useTranscriptTimestampsEnabled();
const showTimestamp = timestampsEnabled && variant !== "compactPreview";
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",
)}
<>
<ActivityRow
className="flex flex-col gap-0.5"
openToneScope="summary"
testId="transcript-same-kind-summary"
title={formatTranscriptTimestampTitle(summary.timestamp)}
>
{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>
<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>
{showTimestamp ? (
<TranscriptRowTimestamp timestamp={summary.timestamp} />
) : null}
</>
);
}
@@ -760,6 +773,13 @@ function TranscriptItemRow({
item: TranscriptItem;
profiles?: UserProfileLookup;
}) {
const variant = useAgentSessionTranscriptVariant();
const timestampsEnabled = useTranscriptTimestampsEnabled();
const showTimestamp = shouldShowTranscriptRowTimestamp(item, {
enabled: timestampsEnabled,
variant,
});
return (
<div key={item.id}>
{SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? (
@@ -772,6 +792,35 @@ function TranscriptItemRow({
item={item}
profiles={profiles}
/>
{showTimestamp ? (
<TranscriptRowTimestamp
messageLink={
item.type === "message" ? getTranscriptMessageLink(item) : null
}
timestamp={item.timestamp}
/>
) : null}
</div>
);
}
/**
* Opt-in per-row timestamp, anchored bottom-left under the row content and
* styled to match the chat/transcript timestamps.
*/
function TranscriptRowTimestamp({
messageLink = null,
timestamp,
}: {
messageLink?: { channelId: string; messageId: string } | null;
timestamp: string;
}) {
return (
<div
className="mt-0.5 flex justify-start"
data-testid="transcript-row-timestamp"
>
<TranscriptTimestamp messageLink={messageLink} timestamp={timestamp} />
</div>
);
}
@@ -1,7 +1,11 @@
import type { ObserverEvent } from "./agentSessionTypes";
import { describeRawEvent } from "./agentSessionTranscript";
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
export function RawEventRail({ events }: { events: ObserverEvent[] }) {
const showTimestamps = useTranscriptTimestampsEnabled();
return (
<section className="flex min-h-0 w-full flex-col text-foreground">
<div className="min-h-0 flex-1">
@@ -21,6 +25,14 @@ export function RawEventRail({ events }: { events: ObserverEvent[] }) {
#{event.seq}
</span>{" "}
{describeRawEvent(event)}
{showTimestamps ? (
<span
className="mt-1 flex justify-start"
data-testid="raw-event-timestamp"
>
<TranscriptTimestamp timestamp={event.timestamp} />
</span>
) : null}
</summary>
<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)}
@@ -5,6 +5,7 @@ import {
getActivityHeadline,
isMeaningfulItem,
isSpineItem,
shouldShowTranscriptRowTimestamp,
} from "./agentSessionTranscriptPresentation.ts";
const baseTimestamp = "2026-06-14T19:00:00.000Z";
@@ -255,3 +256,59 @@ test("two-tier headline: metadata headlines when it is the only activity (sessio
// activityRenderClasses/RawRailActivity.render.test.mjs — they use
// renderToStaticMarkup and would fail if the isRawPayload branch in
// RawRailActivity were removed.
test("shouldShowTranscriptRowTimestamp: off by default (enabled=false)", () => {
assert.equal(
shouldShowTranscriptRowTimestamp(makeTool(), {
enabled: false,
variant: "default",
}),
false,
);
});
test("shouldShowTranscriptRowTimestamp: enabled shows for tools, thoughts, assistant messages", () => {
const options = { enabled: true, variant: "default" };
assert.equal(shouldShowTranscriptRowTimestamp(makeTool(), options), true);
assert.equal(
shouldShowTranscriptRowTimestamp(
makeMessage({ role: "assistant" }),
options,
),
true,
);
assert.equal(
shouldShowTranscriptRowTimestamp(
{
id: "thought:1",
type: "thought",
title: "Thinking",
text: "hmm",
timestamp: baseTimestamp,
},
options,
),
true,
);
});
test("shouldShowTranscriptRowTimestamp: user message bubbles keep their own footer (excluded)", () => {
assert.equal(
shouldShowTranscriptRowTimestamp(
makeMessage({ role: "user", title: "tho" }),
{ enabled: true, variant: "default" },
),
false,
"user bubbles already render a timestamp footer",
);
});
test("shouldShowTranscriptRowTimestamp: compact preview stays dense", () => {
assert.equal(
shouldShowTranscriptRowTimestamp(makeTool(), {
enabled: true,
variant: "compactPreview",
}),
false,
);
});
@@ -1,6 +1,25 @@
import type { TranscriptItem } from "./agentSessionTypes";
import { buildCompactToolSummary } from "./agentSessionToolSummary";
/**
* Whether a polished activity row should render the opt-in timestamp footer.
* User message bubbles already render their own timestamp footer, so they are
* excluded to avoid doubling up. Compact previews stay dense regardless of
* the preference.
*/
export function shouldShowTranscriptRowTimestamp(
item: TranscriptItem,
options: { enabled: boolean; variant: string },
): boolean {
if (!options.enabled || options.variant === "compactPreview") {
return false;
}
if (item.type === "message" && item.role !== "assistant") {
return false;
}
return true;
}
const LIFECYCLE_NOISE = new Set([
"turn started",
"session ready",
@@ -0,0 +1,62 @@
import * as React from "react";
/**
* User preference for showing a timestamp under each activity row — polished
* transcript rows and raw JSON-RPC cards alike. Persisted in localStorage and
* shared across every transcript surface, same as the animation preference.
* This is a device-level UI preference, not workspace-scoped data, so it is
* intentionally not reset on workspace switch. Defaults off to keep the feed
* compact.
*/
const STORAGE_KEY = "buzz:show-transcript-timestamps";
const listeners = new Set<() => void>();
let timestampsEnabled = readStoredPreference();
function readStoredPreference(): boolean {
if (typeof window === "undefined") {
return false;
}
try {
return window.localStorage.getItem(STORAGE_KEY) === "1";
} catch {
return false;
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot(): boolean {
return timestampsEnabled;
}
function getServerSnapshot(): boolean {
return false;
}
/** Update the preference and notify all subscribed components. */
export function setTranscriptTimestampsEnabled(enabled: boolean): void {
timestampsEnabled = enabled;
try {
window.localStorage.setItem(STORAGE_KEY, enabled ? "1" : "0");
} catch {
// Persistence is best-effort; the in-memory value still applies.
}
for (const listener of listeners) {
listener();
}
}
/** Whether polished activity rows should show a timestamp footer. */
export function useTranscriptTimestampsEnabled(): boolean {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
@@ -1,5 +1,11 @@
import * as React from "react";
import { Octagon, Settings, Sparkles, TerminalSquare } from "lucide-react";
import {
Clock3,
Octagon,
Settings,
Sparkles,
TerminalSquare,
} from "lucide-react";
import { toast } from "sonner";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
@@ -41,6 +47,10 @@ import {
setTranscriptAnimationEnabled,
useTranscriptAnimationEnabled,
} from "@/features/agents/ui/transcriptAnimationPreference";
import {
setTranscriptTimestampsEnabled,
useTranscriptTimestampsEnabled,
} from "@/features/agents/ui/transcriptTimestampPreference";
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
type AgentSessionThreadPanelProps = {
@@ -117,6 +127,7 @@ export function AgentSessionThreadPanel({
[rawFeedScopeKey],
);
const animateActivity = useTranscriptAnimationEnabled();
const showTimestamps = useTranscriptTimestampsEnabled();
async function handleInterruptTurn() {
if (!channel) {
return;
@@ -217,9 +228,6 @@ export function AgentSessionThreadPanel({
<Sparkles className="h-4 w-4 text-muted-foreground" />
Show Animations
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
Slide new activity rows in as they arrive.
</span>
</span>
<Switch
aria-hidden="true"
@@ -228,6 +236,32 @@ export function AgentSessionThreadPanel({
tabIndex={-1}
/>
</DropdownMenuItem>
<DropdownMenuItem
className="items-start gap-3"
data-testid="agent-session-toggle-show-timestamps"
onSelect={(event) => {
event.preventDefault();
setTranscriptTimestampsEnabled(!showTimestamps);
}}
title={
showTimestamps
? "Hide per-row activity timestamps."
: "Show a timestamp under each activity row."
}
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-sm font-medium">
<Clock3 className="h-4 w-4 text-muted-foreground" />
Show Timestamps
</span>
</span>
<Switch
aria-hidden="true"
checked={showTimestamps}
className="pointer-events-none mt-0.5"
tabIndex={-1}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="items-start gap-3"