Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-1",
kind: "work",
createdAt,
entry: {
id: "work-1",
createdAt,
label: "Ran command",
tone: "tool",
command: "pnpm test",
},
},
]}
/>,
);

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(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-1",
kind: "work",
createdAt,
entry: {
id: "work-1",
createdAt,
label: "Context compacted",
tone: "info",
},
},
]}
/>,
);

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(
<MessagesTimeline
{...buildProps()}
latestTurn={{
turnId,
state: "completed",
startedAt: workCreatedAt,
completedAt: assistantUpdatedAt,
}}
timelineEntries={[
{
id: "entry-work",
kind: "work",
createdAt: workCreatedAt,
entry: {
id: "work-1",
createdAt: workCreatedAt,
turnId,
label: "Ran command",
tone: "tool",
command: "pnpm test",
},
},
{
id: "entry-assistant",
kind: "message",
createdAt: assistantUpdatedAt,
message: {
id: MessageId.make("message-folded"),
role: "assistant",
text: "Done.",
turnId,
createdAt: assistantUpdatedAt,
updatedAt: assistantUpdatedAt,
streaming: false,
},
},
]}
/>,
);

// 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"));
});
});
59 changes: 53 additions & 6 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Tooltip>
<TooltipTrigger
render={
<span
className="me-1 shrink-0 rounded-md text-muted-foreground text-xs tabular-nums opacity-0 transition-opacity duration-200 group-hover/timeline-row:opacity-100 group-focus-within/timeline-row:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
{...(keyboardReachable ? { tabIndex: 0 } : {})}
Comment on lines +1191 to +1193

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keyboardReachable turns the timestamp into a role-less focusable element, and PlainWorkEntryRow passes it for every row where buildToolCallExpandedBody returns null (info rows, compaction notices, user-input rows, tool rows without a command/detail/changed files). In a long transcript that adds one tab stop per such row, and each stop lands on a <span> with no role or name beyond the time string — assistive tech announces focusable static text, and keyboard users have to traverse the whole activity log to reach the next real control.

The message-row timestamps this component mirrors (user meta row and assistant meta row) deliberately stay non-focusable: the reveal rides on the row's existing control via focus-within, and the time text is still in the DOM for screen readers even while opacity-0. Suggest dropping keyboardReachable/tabIndex here (and the keyboardReachable={!canExpand} prop at the PlainWorkEntryRow call site) so the reveal stays hover/group-focus-within-driven; if a keyboard path is genuinely required for those rows, put the focusable semantics on the row itself rather than on the timestamp span.

Posted via Macroscope — UI Consistency

/>
}
>
{formatDayAwareTimestamp(createdAt, timestampFormat)}
</TooltipTrigger>
<TooltipPopup>{formatChatTimestampTooltip(createdAt, timestampFormat)}</TooltipPopup>
</Tooltip>
);
}

function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {
const ctx = use(TimelineRowCtx);
const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon;
Expand All @@ -1178,9 +1212,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
aria-expanded={row.expanded}
data-scroll-anchor-ignore
onClick={() => 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"
>
<span>{row.label}</span>
<TimelineRowTimestamp createdAt={row.createdAt} timestampFormat={ctx.timestampFormat} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idle timestamps reserve in-flow width

Medium Severity

TimelineRowTimestamp stays in normal flow at opacity-0 with shrink-0, so the formatted time still occupies its full width while idle. On the shrink-wrapped turn-fold button that sits the stamp between the label and chevron, that leaves a blank hole in Worked for … even before hover. Expandable work rows get the same gap between the truncated label and the disclosure chevron. That breaks the idle pixel-identical layout this change aims for, and longer day-aware labels (yesterday at 7:12 PM) widen the hole further.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f8fb116. Configure here.

<Icon className="size-3.5" />
</button>
</div>
Expand Down Expand Up @@ -1381,7 +1416,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
isExpandedToolGroupEntry: boolean;
}) {
const { workspaceRoot } = use(TimelineRowCtx);
const { timestampFormat, workspaceRoot } = use(TimelineRowCtx);
const nonEmptyEntries = useMemo(
() =>
groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
Expand All @@ -1401,6 +1436,7 @@ const WorkGroupSection = memo(function WorkGroupSection({
<SimpleWorkEntryRow
key={workEntry.id}
workEntry={workEntry}
timestampFormat={timestampFormat}
workspaceRoot={workspaceRoot}
isExpandedToolGroupEntry={isExpandedToolGroupEntry}
/>
Expand Down Expand Up @@ -1539,7 +1575,7 @@ function WorkGroupToggleTimelineRow({
return (
<button
type="button"
className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
className="group/tool-group group/timeline-row flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
Expand All @@ -1551,6 +1587,7 @@ function WorkGroupToggleTimelineRow({
/>
</span>
<span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
<TimelineRowTimestamp createdAt={row.createdAt} timestampFormat={ctx.timestampFormat} />
</button>
);
}
Expand Down Expand Up @@ -2504,17 +2541,19 @@ 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 <AgentSpawnCtaRow workEntry={workEntry} />;
}
return (
<PlainWorkEntryRow
workEntry={workEntry}
timestampFormat={timestampFormat}
workspaceRoot={workspaceRoot}
isExpandedToolGroupEntry={isExpandedToolGroupEntry}
/>
Expand All @@ -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";
Expand Down Expand Up @@ -2581,7 +2621,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
return (
<div
className={cn(
"flex flex-col rounded-md px-0.5 transition-colors",
"group/timeline-row flex flex-col rounded-md px-0.5 transition-colors",
isExpandedToolGroupEntry ? "py-0" : "py-0.5",
canExpand &&
"cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
Expand All @@ -2606,6 +2646,13 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
<span className={cn("min-w-0 flex-1 truncate", headingClass)}>{displayText}</span>
</p>
</div>
<TimelineRowTimestamp
createdAt={workEntry.createdAt}
timestampFormat={timestampFormat}
// Rows that cannot expand have no other focusable element, so
// the timestamp itself becomes the keyboard path to its reveal.
keyboardReachable={!canExpand}
/>
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center",
Expand Down
Loading