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}
+