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 {...@@ -9,7 +9,7 @@ import {
9import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";9import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";
10import { renderToStaticMarkup } from "react-dom/server";10import { renderToStaticMarkup } from "react-dom/server";
11import { create, type ReactTestRenderer } from "react-test-renderer";11import { 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";
13import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react";13import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react";
14import { shouldUseRestingComposerLayout } from "../composerFooterLayout";14import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
15import { useComposerFocusState } from "./useComposerFocusState";15import { useComposerFocusState } from "./useComposerFocusState";
...@@ -146,7 +146,9 @@ function matchMedia() {...@@ -146,7 +146,9 @@ function matchMedia() {
146let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline;146let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline;
147let resolvePreviewAnnotationImage: typeof import("./MessagesTimeline").resolvePreviewAnnotationImage;147let resolvePreviewAnnotationImage: typeof import("./MessagesTimeline").resolvePreviewAnnotationImage;
148148
149beforeAll(async () => {149const ElementStub = class ElementStub {};
150
151function stubDomGlobals() {
150 const classList = {152 const classList = {
151 add: () => {},153 add: () => {},
152 remove: () => {},154 remove: () => {},
...@@ -154,6 +156,7 @@ beforeAll(async () => {...@@ -154,6 +156,7 @@ beforeAll(async () => {
154 contains: () => false,156 contains: () => false,
155 };157 };
156158
159 vi.stubGlobal("Element", ElementStub);
157 vi.stubGlobal("localStorage", {160 vi.stubGlobal("localStorage", {
158 getItem: () => null,161 getItem: () => null,
159 setItem: () => {},162 setItem: () => {},
...@@ -161,6 +164,7 @@ beforeAll(async () => {...@@ -161,6 +164,7 @@ beforeAll(async () => {
161 clear: () => {},164 clear: () => {},
162 });165 });
163 vi.stubGlobal("window", {166 vi.stubGlobal("window", {
167 Element: ElementStub,
164 matchMedia,168 matchMedia,
165 addEventListener: () => {},169 addEventListener: () => {},
166 removeEventListener: () => {},170 removeEventListener: () => {},
...@@ -177,10 +181,17 @@ beforeAll(async () => {...@@ -177,10 +181,17 @@ beforeAll(async () => {
177 offsetHeight: 0,181 offsetHeight: 0,
178 },182 },
179 });183 });
184}
180185
186beforeAll(async () => {
187 stubDomGlobals();
181 ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline"));188 ({ MessagesTimeline, resolvePreviewAnnotationImage } = await import("./MessagesTimeline"));
182}, 30_000);189}, 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
184const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");195const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
185const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";196const 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({...@@ -2063,12 +2063,49 @@ function RevertUserMessageButton({
2063 );2063 );
2064}2064}
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
2066function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {2103function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-fold" }> }) {
2067 const ctx = use(TimelineRowCtx);2104 const ctx = use(TimelineRowCtx);
2068 const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon;2105 const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon;
20692106
2070 return (2107 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">
2072 <button2109 <button
2073 type="button"2110 type="button"
2074 aria-expanded={row.expanded}2111 aria-expanded={row.expanded}
...@@ -2079,6 +2116,11 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-...@@ -2079,6 +2116,11 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
2079 <span>{row.label}</span>2116 <span>{row.label}</span>
2080 <Icon className="size-3.5" />2117 <Icon className="size-3.5" />
2081 </button>2118 </button>
2119 <TimelineRowTimestamp
2120 createdAt={row.createdAt}
2121 timestampFormat={ctx.timestampFormat}
2122 className="ms-auto"
2123 />
2082 </div>2124 </div>
2083 );2125 );
2084}2126}
...@@ -2770,7 +2812,7 @@ function WorkGroupToggleTimelineRow({...@@ -2770,7 +2812,7 @@ function WorkGroupToggleTimelineRow({
2770 return (2812 return (
2771 <button2813 <button
2772 type="button"2814 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"
2774 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}2816 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
2775 aria-expanded={row.expanded}2817 aria-expanded={row.expanded}
2776 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}2818 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
...@@ -2786,6 +2828,7 @@ function WorkGroupToggleTimelineRow({...@@ -2786,6 +2828,7 @@ function WorkGroupToggleTimelineRow({
2786 />2828 />
2787 </span>2829 </span>
2788 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>2830 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
2831 <TimelineRowTimestamp createdAt={row.createdAt} timestampFormat={ctx.timestampFormat} />
2789 </button>2832 </button>
2790 );2833 );
2791}2834}
...@@ -4234,7 +4277,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -4234,7 +4277,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
4234 onToggleEntry?: ((collapsed: boolean) => void) | undefined;4277 onToggleEntry?: ((collapsed: boolean) => void) | undefined;
4235}) {4278}) {
4236 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;4279 const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props;
4237 const { threadRef, onImageExpand } = use(TimelineRowCtx);4280 const { threadRef, onImageExpand, timestampFormat } = use(TimelineRowCtx);
4238 const groupView = use(WorkGroupViewCtx);4281 const groupView = use(WorkGroupViewCtx);
4239 const [expanded, setExpanded] = useState(4282 const [expanded, setExpanded] = useState(
4240 () => groupView?.state.expandedEntries.has(workEntry.id) ?? false,4283 () => groupView?.state.expandedEntries.has(workEntry.id) ?? false,
...@@ -4336,7 +4379,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -4336,7 +4379,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
4336 return (4379 return (
4337 <div4380 <div
4338 className={cn(4381 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",
4340 isExpandedToolGroupEntry ? "py-0" : "py-0.5",4383 isExpandedToolGroupEntry ? "py-0" : "py-0.5",
4341 expanded && "mb-1",4384 expanded && "mb-1",
4342 canExpand &&4385 canExpand &&
...@@ -4406,6 +4449,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {...@@ -4406,6 +4449,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
4406 )}4449 )}
4407 />4450 />
4408 </span>4451 </span>
4452 <TimelineRowTimestamp createdAt={workEntry.createdAt} timestampFormat={timestampFormat} />
4409 </div>4453 </div>
4410 </div>4454 </div>
4411 {expanded && viewedImage && threadRef ? (4455 {expanded && viewedImage && threadRef ? (