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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user