From f8fb116a24d65725f384f545f443d41a89248076 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 29 Aug 2026 19:59:55 +1000 Subject: [PATCH] feat(web): reveal timestamps on tool rows and turn folds Collapsed tool-group rows, plain work-log rows, and the "Worked for" turn fold now carry the same hover-revealed wall-clock timestamp (with a full-date tooltip) that message rows already use, so the desktop app shows when each tool call or agent turn happened. The transient "Running X" live row is intentionally left without one. Rows with no other focusable element make the timestamp itself the tab stop so keyboard users can reveal it too. Co-Authored-By: Claude --- .../components/chat/MessagesTimeline.test.tsx | 119 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 59 ++++++++- 2 files changed, 172 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 67060a28d647..ccb51900135b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -4,6 +4,7 @@ import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; +import { formatDayAwareTimestamp } from "../../timestampFormat"; vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; @@ -1209,6 +1210,9 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Running pnpm"); expect(markup).toContain("live-activity-focus"); + // The live row is transient (it becomes a collapsed group when the turn + // settles), so it intentionally carries no timestamp. + expect(markup).not.toContain(formatDayAwareTimestamp(MESSAGE_CREATED_AT, "locale")); }); it("scopes a live row failure to the tool named by the row", () => { @@ -1453,4 +1457,119 @@ describe("MessagesTimeline", () => { expect(markup).toContain("lucide-x"); expect(markup).toContain("text-destructive"); }); + + it("shows a hover timestamp on collapsed tool group rows", () => { + const createdAt = "2026-03-17T19:12:28.000Z"; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Ran 1 command"); + expect(markup).toContain("group/timeline-row"); + // The timestamp must be hidden until the row is hovered or focused, so + // the assertions pin the reveal itself, not just the text. + expect(markup).toContain("opacity-0"); + expect(markup).toContain("group-hover/timeline-row:opacity-100"); + expect(markup).toContain("group-focus-within/timeline-row:opacity-100"); + expect(markup).toContain(formatDayAwareTimestamp(createdAt, "locale")); + }); + + it("shows a hover timestamp on non-tool activity summaries", () => { + const createdAt = "2026-03-17T19:12:28.000Z"; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Context compacted"); + expect(markup).toContain("group/timeline-row"); + expect(markup).toContain("group-hover/timeline-row:opacity-100"); + expect(markup).toContain("group-focus-within/timeline-row:opacity-100"); + // The unified activity-summary button is already the keyboard path. + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain(formatDayAwareTimestamp(createdAt, "locale")); + }); + + it("shows a hover timestamp on the worked-for turn fold", () => { + const turnId = TurnId.make("turn-folded"); + const workCreatedAt = "2026-03-17T19:12:28.000Z"; + const assistantUpdatedAt = "2026-03-17T19:14:30.000Z"; + const markup = renderToStaticMarkup( + , + ); + + // The work entry folds behind the turn fold, so the fold row is the only + // place the work entry's start time can render from. + expect(markup).toContain("Worked for"); + expect(markup).toContain(formatDayAwareTimestamp(workCreatedAt, "locale")); + // The assistant metadata row keeps its own (later) timestamp. + expect(markup).toContain(formatDayAwareTimestamp(assistantUpdatedAt, "locale")); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d8c88c088648..b703b2f93bd6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1167,6 +1167,40 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { ); } +/** + * Hover-revealed wall-clock time with a full-date tooltip — the same metadata + * presentation as message rows, for work entries and turn folds. The parent + * carries the `group/timeline-row` class that drives the reveal; keyboard + * focus anywhere in that parent reveals it too, since the span itself is not + * focusable. Rows without any other focusable element pass + * `keyboardReachable` so the span becomes the tab stop and tooltip target. + */ +function TimelineRowTimestamp({ + createdAt, + timestampFormat, + keyboardReachable = false, +}: { + createdAt: string; + timestampFormat: TimestampFormat; + keyboardReachable?: boolean; +}) { + return ( + + + } + > + {formatDayAwareTimestamp(createdAt, timestampFormat)} + + {formatChatTimestampTooltip(createdAt, timestampFormat)} + + ); +} + function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; @@ -1178,9 +1212,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.turnId)} - className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" + className="group/timeline-row flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > {row.label} + @@ -1381,7 +1416,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries: Extract["groupedEntries"]; isExpandedToolGroupEntry: boolean; }) { - const { workspaceRoot } = use(TimelineRowCtx); + const { timestampFormat, workspaceRoot } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)), @@ -1401,6 +1436,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ @@ -1539,7 +1575,7 @@ function WorkGroupToggleTimelineRow({ return ( ); } @@ -2504,10 +2541,11 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; + timestampFormat: TimestampFormat; workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { workEntry, timestampFormat, workspaceRoot, isExpandedToolGroupEntry } = props; // Before any hooks: spawn CTA rows render their own component. if (workEntry.agentSpawn) { return ; @@ -2515,6 +2553,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { return ( @@ -2523,10 +2562,11 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; + timestampFormat: TimestampFormat; workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { workEntry, timestampFormat, workspaceRoot, isExpandedToolGroupEntry } = props; const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; @@ -2581,7 +2621,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { return (
{displayText}

+