authorgravatar for saphid@gmail.comAlex Southwell <saphid@gmail.com> 2026-09-16 16:57:59+10:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 12:27:59+05:30
logb900fc94ebe630155783c07324c58af7e6387a03
tree94c7608f32f9b9f527746230a1218e9f32949604
parentbf55408a5cddbb0f3f7f50d538398e315b5407d0
signature Signed by PGP key B5690EEEBB952194

feat(web): reveal timestamps on tool rows and turn folds (#8641)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

2 files changed, 61 insertions(+), 6 deletions(-)

apps/web/src/components/chat/MessagesTimeline.test.tsx+13-2
......@@ -9,7 +9,7 @@ import {
99import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";
1010import { renderToStaticMarkup } from "react-dom/server";
1111import { create, type ReactTestRenderer } from "react-test-renderer";
12import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
12import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1313import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react";
1414import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
1515import { useComposerFocusState } from "./useComposerFocusState";
......@@ -146,7 +146,9 @@ function matchMedia() {
146146let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline;
147147let resolvePreviewAnnotationImage: typeof import("./MessagesTimeline").resolvePreviewAnnotationImage;
148148
149beforeAll(async () => {
149const ElementStub = class ElementStub {};
150
151function stubDomGlobals() {
150152 const classList = {
151153 add: () => {},
152154 remove: () => {},
......@@ -154,6 +156,7 @@ beforeAll(async () => {
154156 contains: () => false,
155157 };
156158
159 vi.stubGlobal("Element", ElementStub);
157160 vi.stubGlobal("localStorage", {
158161 getItem: () => null,
159162 setItem: () => {},
......@@ -161,6 +164,7 @@ beforeAll(async () => {
161164 clear: () => {},
162165 });
163166 vi.stubGlobal("window", {
167 Element: ElementStub,
164168 matchMedia,
165169 addEventListener: () => {},
166170 removeEventListener: () => {},
......@@ -177,10 +181,17 @@ beforeAll(async () => {
177181 offsetHeight: 0,
178182 },
179183 });
184}
180185
186beforeAll(async () => {
187 stubDomGlobals();
181188 ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline"));
182189}, 30_000);
183190
191// The scroll-settling test clears every global stub; mounted timeline rows
192// still touch `window` through the tooltip's focus handling.
193beforeEach(stubDomGlobals);
194
184195const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
185196const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
186197
apps/web/src/components/chat/MessagesTimeline.tsx+48-4
......@@ -2063,12 +2063,49 @@ function RevertUserMessageButton({
20632063 );
20642064}
20652065
2066/**
2067 * Hover-revealed wall-clock time with a full-date tooltip — the same metadata
2068 * presentation as message rows, for work entries and turn folds. The parent
2069 * carries `group/timeline-row`; hover or focus on an existing control reveals
2070 * the time without adding a tab stop. Hidden timestamps stay outside the row
2071 * layout. Visibility changes immediately so leaving flow cannot overlap text
2072 * during a fade-out. Render it as the row's rightmost flex child so the
2073 * revealed time lands at the right edge, clear of disclosure controls.
2074 */
2075function TimelineRowTimestamp({
2076 createdAt,
2077 timestampFormat,
2078 className,
2079}: {
2080 createdAt: string;
2081 timestampFormat: TimestampFormat;
2082 className?: string;
2083}) {
2084 return (
2085 <Tooltip>
2086 <TooltipTrigger
2087 render={
2088 <span
2089 className={cn(
2090 "pointer-events-none absolute me-1 shrink-0 whitespace-nowrap rounded-md text-muted-foreground text-xs tabular-nums opacity-0 group-hover/timeline-row:pointer-events-auto group-hover/timeline-row:static group-hover/timeline-row:opacity-100 group-focus-within/timeline-row:pointer-events-auto group-focus-within/timeline-row:static group-focus-within/timeline-row:opacity-100",
2091 className,
2092 )}
2093 />
2094 }
2095 >
2096 {formatDayAwareTimestamp(createdAt, timestampFormat)}
2097 </TooltipTrigger>
2098 <TooltipPopup>{formatChatTimestampTooltip(createdAt, timestampFormat)}</TooltipPopup>
2099 </Tooltip>
2100 );
2101}
2102
20662103function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {
20672104 const ctx = use(TimelineRowCtx);
20682105 const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon;
20692106
20702107 return (
2071 <div className="border-b border-border/60 pb-2 pt-1">
2108 <div className="group/timeline-row relative flex items-center gap-1 border-b border-border/60 pb-2 pe-0.5 pt-1">
20722109 <button
20732110 type="button"
20742111 aria-expanded={row.expanded}
......@@ -2079,6 +2116,11 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
20792116 <span>{row.label}</span>
20802117 <Icon className="size-3.5" />
20812118 </button>
2119 <TimelineRowTimestamp
2120 createdAt={row.createdAt}
2121 timestampFormat={ctx.timestampFormat}
2122 className="ms-auto"
2123 />
20822124 </div>
20832125 );
20842126}
......@@ -2770,7 +2812,7 @@ function WorkGroupToggleTimelineRow({
27702812 return (
27712813 <button
27722814 type="button"
2773 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"
2815 className="group/tool-group group/timeline-row relative 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"
27742816 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
27752817 aria-expanded={row.expanded}
27762818 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
......@@ -2786,6 +2828,7 @@ function WorkGroupToggleTimelineRow({
27862828 />
27872829 </span>
27882830 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
2831 <TimelineRowTimestamp createdAt={row.createdAt} timestampFormat={ctx.timestampFormat} />
27892832 </button>
27902833 );
27912834}
......@@ -4234,7 +4277,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
42344277 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
42354278}) {
42364279 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;
4237 const { threadRef, onImageExpand } = use(TimelineRowCtx);
4280 const { threadRef, onImageExpand, timestampFormat } = use(TimelineRowCtx);
42384281 const groupView = use(WorkGroupViewCtx);
42394282 const [expanded, setExpanded] = useState(
42404283 () => groupView?.state.expandedEntries.has(workEntry.id) ?? false,
......@@ -4336,7 +4379,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
43364379 return (
43374380 <div
43384381 className={cn(
4339 "flex flex-col rounded-md px-0.5 transition-colors",
4382 "group/timeline-row relative flex flex-col rounded-md px-0.5 transition-colors",
43404383 isExpandedToolGroupEntry ? "py-0" : "py-0.5",
43414384 expanded && "mb-1",
43424385 canExpand &&
......@@ -4406,6 +4449,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
44064449 )}
44074450 />
44084451 </span>
4452 <TimelineRowTimestamp createdAt={workEntry.createdAt} timestampFormat={timestampFormat} />
44094453 </div>
44104454 </div>
44114455 {expanded && viewedImage && threadRef ? (