feat(FN-905): add relative timestamps to agent log entries

- Display relative timestamps (e.g., '2m ago') alongside absolute timestamps in AgentLogViewer
- Add formatRelativeTime utility for human-readable time formatting
- Update existing and add new tests covering relative timestamp rendering
This commit is contained in:
gsxdsm
2026-04-04 11:32:00 -07:00
parent 039633ab9e
commit ef881a4ac7
2 changed files with 151 additions and 13 deletions

View File

@@ -5,6 +5,21 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
function formatTimestamp(iso: string): string {
const date = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString();
}
const markdownComponents: Components = {
pre: ({ children, ...props }) => (
<pre
@@ -215,6 +230,21 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
</span>
) : null;
const timestampSpan = (
<span
className="agent-log-timestamp"
data-testid="agent-log-timestamp"
style={{
color: "var(--text-muted, #888)",
fontSize: "10px",
marginRight: "6px",
opacity: 0.7,
}}
>
{formatTimestamp(entry.timestamp)}
</span>
);
if (entry.type === "tool") {
return (
<div
@@ -228,7 +258,7 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
background: "rgba(124, 92, 191, 0.08)",
}}
>
{agentBadge} {entry.text}
{agentBadge}{timestampSpan} {entry.text}
{entry.detail && (
<span
className="agent-log-tool-detail"
@@ -256,7 +286,7 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
opacity: 0.7,
}}
>
{agentBadge}
{agentBadge}{timestampSpan}
{renderMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entry.text}
@@ -282,7 +312,7 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
fontSize: "12px",
}}
>
{agentBadge} {entry.text}
{agentBadge}{timestampSpan} {entry.text}
{entry.detail && (
<span
className="agent-log-tool-detail"
@@ -312,7 +342,7 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
fontSize: "12px",
}}
>
{agentBadge} {entry.text}
{agentBadge}{timestampSpan} {entry.text}
{entry.detail && (
<span
className="agent-log-tool-detail"
@@ -331,7 +361,7 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
// Default: text entries
return (
<span key={i} className="agent-log-text">
{agentBadge}
{agentBadge}{timestampSpan}
{renderMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entry.text}

View File

@@ -33,8 +33,9 @@ describe("AgentLogViewer", () => {
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(2);
// Reversed order: second chunk first, then first chunk
expect(textSpans[0].textContent).toBe("second chunk");
expect(textSpans[1].textContent).toBe("first chunk");
// Each entry includes a timestamp span before the text content
expect(textSpans[0].textContent).toContain("second chunk");
expect(textSpans[1].textContent).toContain("first chunk");
});
it("renders tool entries with distinct styling", () => {
@@ -57,8 +58,8 @@ describe("AgentLogViewer", () => {
// Reversed order: Done! (text), Bash (tool), Starting... (text)
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(2);
expect(textSpans[0].textContent).toBe("Done!");
expect(textSpans[1].textContent).toBe("Starting...");
expect(textSpans[0].textContent).toContain("Done!");
expect(textSpans[1].textContent).toContain("Starting...");
const toolDivs = container.querySelectorAll(".agent-log-tool");
expect(toolDivs).toHaveLength(1);
@@ -410,6 +411,112 @@ describe("AgentLogViewer", () => {
});
});
describe("timestamp display", () => {
it("renders a timestamp span for each log entry", () => {
const entries = [
makeEntry({ text: "hello", type: "text" }),
makeEntry({ text: "Read", type: "tool" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const timestamps = container.querySelectorAll(".agent-log-timestamp");
expect(timestamps).toHaveLength(2);
});
it("renders relative timestamps for recent entries", () => {
const recentTimestamp = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const entries = [
makeEntry({ text: "hello", type: "text", timestamp: recentTimestamp }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const timestamp = container.querySelector(".agent-log-timestamp");
expect(timestamp).toBeTruthy();
expect(timestamp!.textContent).toBe("5m ago");
});
it("renders 'just now' for entries less than a minute old", () => {
const recentTimestamp = new Date(Date.now() - 30 * 1000).toISOString();
const entries = [
makeEntry({ text: "hello", type: "text", timestamp: recentTimestamp }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const timestamp = container.querySelector(".agent-log-timestamp");
expect(timestamp!.textContent).toBe("just now");
});
it("renders hours ago for older entries", () => {
const olderTimestamp = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
const entries = [
makeEntry({ text: "hello", type: "text", timestamp: olderTimestamp }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const timestamp = container.querySelector(".agent-log-timestamp");
expect(timestamp!.textContent).toBe("3h ago");
});
it("renders days ago for entries older than a day", () => {
const oldTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
const entries = [
makeEntry({ text: "hello", type: "text", timestamp: oldTimestamp }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const timestamp = container.querySelector(".agent-log-timestamp");
expect(timestamp!.textContent).toBe("2d ago");
});
it("renders locale date for entries older than 7 days", () => {
const veryOldTimestamp = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const entries = [
makeEntry({ text: "hello", type: "text", timestamp: veryOldTimestamp }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const timestamp = container.querySelector(".agent-log-timestamp");
// Should be a locale date string, not a relative time
expect(timestamp!.textContent).not.toContain("ago");
expect(timestamp!.textContent).not.toBe("just now");
});
it("styles timestamps with muted color and small font", () => {
const entries = [makeEntry({ text: "hello", type: "text" })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const timestamp = container.querySelector(".agent-log-timestamp") as HTMLElement;
expect(timestamp).toBeTruthy();
expect(timestamp.style.fontSize).toBe("10px");
expect(timestamp.style.opacity).toBe("0.7");
});
it("includes timestamp on tool entries", () => {
const entries = [makeEntry({ text: "Bash", type: "tool" })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toolDiv = container.querySelector(".agent-log-tool");
expect(toolDiv).toBeTruthy();
expect(toolDiv!.querySelector(".agent-log-timestamp")).toBeTruthy();
});
it("includes timestamp on tool_result entries", () => {
const entries = [makeEntry({ text: "ok", type: "tool_result" })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const resultDiv = container.querySelector(".agent-log-tool-result");
expect(resultDiv).toBeTruthy();
expect(resultDiv!.querySelector(".agent-log-timestamp")).toBeTruthy();
});
it("includes timestamp on tool_error entries", () => {
const entries = [makeEntry({ text: "fail", type: "tool_error" })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const errorDiv = container.querySelector(".agent-log-tool-error");
expect(errorDiv).toBeTruthy();
expect(errorDiv!.querySelector(".agent-log-timestamp")).toBeTruthy();
});
it("includes timestamp on thinking entries", () => {
const entries = [makeEntry({ text: "hmm", type: "thinking" })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const thinkingSpan = container.querySelector(".agent-log-thinking");
expect(thinkingSpan).toBeTruthy();
expect(thinkingSpan!.querySelector(".agent-log-timestamp")).toBeTruthy();
});
});
describe("horizontal overflow prevention", () => {
it("sets overflowX hidden on the viewer container", () => {
const longString = "A".repeat(300);
@@ -516,8 +623,9 @@ describe("AgentLogViewer", () => {
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(1);
expect(textSpans[0].textContent).toBe(longText);
expect(textSpans[0].textContent!.length).toBe(5000);
// Timestamp is included as a separate span before the text
expect(textSpans[0].textContent).toContain(longText);
expect(textSpans[0].querySelector(".agent-log-timestamp")).toBeTruthy();
});
it("renders very long detail text without truncation", () => {
@@ -582,7 +690,7 @@ describe("AgentLogViewer", () => {
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(1);
expect(textSpans[0].textContent).toBe("Hello world, this is plain text.");
expect(textSpans[0].textContent).toContain("Hello world, this is plain text.");
});
it("renders inline markdown elements (bold, italic, inline code)", () => {
@@ -722,7 +830,7 @@ describe("AgentLogViewer", () => {
// Text should now show raw markdown syntax literally
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(1);
expect(textSpans[0].textContent).toBe("**bold** and *italic*");
expect(textSpans[0].textContent).toContain("**bold** and *italic*");
// No markdown elements should be present
expect(textSpans[0].querySelector("strong")).toBeNull();
expect(textSpans[0].querySelector("em")).toBeNull();