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}

+