FN-7241: add per-block task chat timestamps

Show relative timestamps throughout task-detail chat transcript blocks.

- Add reusable timestamp rendering for text, tool, thinking, and user message blocks.
- Align timestamp styling with quiet, responsive task chat metadata treatments.
- Cover empty, loading, invalid timestamp, block timestamp, and tool fallback cases.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-7241-task-chat-block-timestamps.md   |  7 ++
 packages/dashboard/app/components/TaskChatTab.css  | 44 +++++++++++-
 packages/dashboard/app/components/TaskChatTab.tsx  | 65 +++++++++++++++---
 .../app/components/__tests__/TaskChatTab.test.tsx  | 80 ++++++++++++++++++++--
 4 files changed, 179 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7241

Fusion-Task-Lineage: 87470de1-4113-4c6a-832d-ced4b1675a76

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 15:28:25 -07:00
parent 3886e585dd
commit c6c4a003f9
4 changed files with 179 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show timestamps on each task-detail chat block.
category: feature
dev: Adds per-block TaskChatTab timestamp rendering and regression coverage for text, tool, thinking, and user blocks.

View File

@@ -382,8 +382,50 @@ FN-7240 raises the FN-7225 follow-up tool-call typography to the readable base s
font-weight: 600;
}
.task-chat-entry-kicker {
/*
FNXC:TaskChatTimestamps 2026-06-29-14:47:
FN-7241 adds timestamps inside individual task-detail transcript blocks. Keep block-level timestamps visually quieter than content, token-sized, and flex-contained so text bubbles, tool rows, native details summaries, and thinking summaries remain readable in both wide and narrow hosts.
*/
.task-chat-entry-meta .task-chat-timestamp,
.task-chat-entry-label-row .task-chat-timestamp,
.task-chat-tool-group-summary .task-chat-timestamp,
.task-chat-thinking-summary .task-chat-timestamp {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
color: var(--text-muted);
font-size: calc(var(--space-md) - (var(--space-xs) / 2));
font-weight: 400;
line-height: 1.2;
white-space: nowrap;
}
.task-chat-entry-meta .task-chat-timestamp {
margin-left: auto;
}
.task-chat-entry-label-row .task-chat-timestamp,
.task-chat-tool-group-summary .task-chat-timestamp,
.task-chat-thinking-summary .task-chat-timestamp {
max-inline-size: 40%;
overflow: hidden;
text-overflow: ellipsis;
}
.task-chat-entry-label-row,
.task-chat-entry-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
margin-bottom: calc(var(--space-xs) / 2);
}
.task-chat-entry-meta {
justify-content: flex-end;
}
.task-chat-entry-kicker {
color: var(--text-muted);
font-size: var(--space-md);
font-weight: 600;

View File

@@ -162,6 +162,46 @@ function getTimestampMs(value: string): number {
return Number.isFinite(parsed) ? parsed : 0;
}
function getLatestEntryTimestamp(entries: readonly AgentLogEntry[]): string {
let latestTimestamp = "";
let latestTimestampMs = 0;
for (const entry of entries) {
const timestampMs = getTimestampMs(entry.timestamp);
if (timestampMs > latestTimestampMs) {
latestTimestamp = entry.timestamp;
latestTimestampMs = timestampMs;
}
}
return latestTimestamp;
}
/*
FNXC:TaskChatTimestamps 2026-06-29-14:37:
Task Detail Chat requires per-block timestamps in addition to existing group and user headers so operators can scan when each text, tool, thinking, or steering block was produced. Reuse the shared relative-time formatter and return null for empty or invalid dates so transcript blocks never render timestamp shells without meaningful time text.
*/
function getRelativeTimestamp(timestamp: string | undefined): string {
return timestamp ? formatRelativeTimeAgo(timestamp) : "";
}
function TaskChatTimestamp({ timestamp, testId = "task-chat-block-time", label = "Message timestamp" }: { timestamp: string | undefined; testId?: string; label?: string }) {
const relativeTime = getRelativeTimestamp(timestamp);
if (!relativeTime) return null;
return (
<span className="task-chat-timestamp" data-testid={testId} aria-label={label}>
{relativeTime}
</span>
);
}
function TaskChatTimestampMeta({ timestamp, label }: { timestamp: string | undefined; label: string }) {
if (!getRelativeTimestamp(timestamp)) return null;
return (
<div className="task-chat-entry-meta">
<TaskChatTimestamp timestamp={timestamp} label={label} />
</div>
);
}
function getLatestTranscriptTimestampMs(entries: readonly AgentLogEntry[], userMessages: readonly UserChatMessage[]): number {
return Math.max(
0,
@@ -334,6 +374,7 @@ function TaskChatText({ entries }: { entries: AgentLogEntry[] }) {
className={`task-chat-entry task-chat-entry--${firstEntry.type.replace("_", "-")}`}
data-testid={`task-chat-entry-${firstEntry.type}`}
>
<TaskChatTimestampMeta timestamp={getLatestEntryTimestamp(entries)} label="Text block timestamp" />
<div className="markdown-body task-chat-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entries.map((entry) => entry.text).join("")}
@@ -351,7 +392,10 @@ function TaskChatToolEntry({ entry }: { entry: AgentLogEntry }) {
className={`task-chat-tool-entry task-chat-tool-entry--${entry.type.replace("_", "-")}`}
data-testid={`task-chat-entry-${entry.type}`}
>
<div className="task-chat-entry-kicker">{formatEntryLabel(entry, t)}</div>
<div className="task-chat-entry-label-row">
<span className="task-chat-entry-kicker">{formatEntryLabel(entry, t)}</span>
<TaskChatTimestamp timestamp={entry.timestamp} label="Tool entry timestamp" />
</div>
<div className="task-chat-entry-text">{entry.text}</div>
{entry.detail ? <pre className="task-chat-tool-detail">{linkifyFilePaths(entry.detail)}</pre> : null}
</article>
@@ -393,8 +437,9 @@ function TaskChatToolInvocation({ row }: { row: Extract<TaskChatToolGroupRow, {
return (
<article className={className} data-testid="task-chat-tool-invocation">
<div className="task-chat-entry-kicker">
{completionLabel ? t("taskChat.toolCallTo", "Tool call → {{label}}", { label: completionLabel }) : t("taskChat.toolCall", "Tool call")}
<div className="task-chat-entry-label-row">
<span className="task-chat-entry-kicker">{completionLabel ? t("taskChat.toolCallTo", "Tool call → {{label}}", { label: completionLabel }) : t("taskChat.toolCall", "Tool call")}</span>
<TaskChatTimestamp timestamp={completion?.timestamp ?? row.call.timestamp} label="Tool invocation timestamp" />
</div>
<div className="task-chat-entry-text">{row.call.text}</div>
{row.call.detail ? (
@@ -440,6 +485,7 @@ function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) {
{formatErrorCount(errorCount, t)}
</span>
) : null}
<TaskChatTimestamp timestamp={getLatestEntryTimestamp(entries)} label="Tool group timestamp" />
</summary>
<div className="task-chat-tool-group-entries">
{rows.map((row) => (
@@ -460,7 +506,10 @@ function TaskChatThinking({ entries }: { entries: AgentLogEntry[] }) {
return (
<details className="task-chat-thinking" data-testid="task-chat-thinking" open>
<summary className="task-chat-thinking-summary">{t("taskChat.thinking", "Thinking")}</summary>
<summary className="task-chat-thinking-summary">
<span>{t("taskChat.thinking", "Thinking")}</span>
<TaskChatTimestamp timestamp={getLatestEntryTimestamp(entries)} label="Thinking block timestamp" />
</summary>
<div className="task-chat-thinking-body">
<div
className="markdown-body task-chat-markdown task-chat-thinking-markdown"
@@ -504,6 +553,7 @@ function TaskChatUserMessage({ message }: { message: UserChatMessage }) {
) : null}
</div>
<article className="task-chat-entry task-chat-entry--user" data-testid="task-chat-entry-user">
<TaskChatTimestampMeta timestamp={message.createdAt} label="User message block timestamp" />
<div className="markdown-body task-chat-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{message.text}
@@ -839,7 +889,6 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
const segments = segmentGroupEntries(item.entries);
const latestEntryTimestamp = item.entries[item.entries.length - 1]?.timestamp ?? "";
const relativeTime = formatRelativeTimeAgo(latestEntryTimestamp);
const modelInfo = getModelForRole(task, item.role, item.entries, effectiveModels);
return (
<section className="task-chat-group" key={`${item.role ?? "agent"}-${itemIndex}`} aria-label={t("taskChat.agentMessages", "{{label}} messages", { label: item.label })}>
@@ -849,11 +898,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
<div className="task-chat-role-label">{item.label}</div>
<div className="task-chat-group-meta">
<span>{formatEntryCount(item.entries.length, t)}</span>
{relativeTime ? (
<span className="task-chat-timestamp" data-testid="task-chat-group-time">
{relativeTime}
</span>
) : null}
<TaskChatTimestamp timestamp={latestEntryTimestamp} testId="task-chat-group-time" label="Agent group timestamp" />
</div>
</div>
</header>

View File

@@ -365,6 +365,19 @@ describe("TaskChatTab", () => {
expect(within(transcript).getByText(/No agent output yet/)).toBeTruthy();
expect(within(transcript).queryByTestId("task-chat-group-time")).not.toBeInTheDocument();
expect(within(transcript).queryByTestId("task-chat-user-time")).not.toBeInTheDocument();
expect(within(transcript).queryByTestId("task-chat-block-time")).not.toBeInTheDocument();
expect(transcript).not.toHaveTextContent(/NaN|Invalid Date/);
});
it("renders loading state without timestamp shells or invalid-date text", () => {
mockLogs([], true);
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
const transcript = screen.getByTestId("task-chat-transcript");
expect(within(transcript).getByText("Loading agent output…")).toBeTruthy();
expect(within(transcript).queryByTestId("task-chat-group-time")).not.toBeInTheDocument();
expect(within(transcript).queryByTestId("task-chat-user-time")).not.toBeInTheDocument();
expect(within(transcript).queryByTestId("task-chat-block-time")).not.toBeInTheDocument();
expect(transcript).not.toHaveTextContent(/NaN|Invalid Date/);
});
@@ -602,6 +615,37 @@ describe("TaskChatTab", () => {
expect(within(groupMeta as HTMLElement).getByTestId("task-chat-group-time")).toHaveTextContent("2m ago");
});
it("renders per-block timestamps for text tool thinking and user blocks", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z"));
mockLogs([
makeEntry({ agent: "executor", text: "older text chunk ", timestamp: "2026-06-17T14:58:30.000Z" }),
makeEntry({ agent: "executor", text: "latest text chunk", timestamp: "2026-06-17T14:59:00.000Z" }),
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test", timestamp: "2026-06-17T14:56:00.000Z" }),
makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok", timestamp: "2026-06-17T14:57:00.000Z" }),
makeEntry({ agent: "executor", type: "thinking", text: "first thought", timestamp: "2026-06-17T14:54:00.000Z" }),
makeEntry({ agent: "executor", type: "thinking", text: "latest thought", timestamp: "2026-06-17T14:55:00.000Z" }),
]);
render(
<TaskChatTab
task={makeTask({
steeringComments: [makeSteeringComment({ id: "block-user", text: "block-level user guidance", createdAt: "2026-06-17T14:52:00.000Z" })],
})}
active
addToast={vi.fn()}
/>,
);
const textBlock = screen.getByTestId("task-chat-entry-text");
expect(textBlock).toHaveTextContent("older text chunk latest text chunk");
expect(within(textBlock).getByLabelText("Text block timestamp")).toHaveTextContent("1m ago");
expect(screen.getByLabelText("Tool group timestamp")).toHaveTextContent("3m ago");
expect(screen.getByLabelText("Tool invocation timestamp")).toHaveTextContent("3m ago");
expect(screen.getByLabelText("Thinking block timestamp")).toHaveTextContent("5m ago");
expect(screen.getByLabelText("User message block timestamp")).toHaveTextContent("8m ago");
});
it("keeps agent and user timestamp parity in the inline chat surface", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z"));
@@ -868,6 +912,7 @@ describe("TaskChatTab", () => {
expect(screen.getByText("Tool call → error")).toBeVisible();
expect(screen.getByText("Error")).toBeVisible();
expect(screen.getByText("stderr")).toBeVisible();
expect(screen.getByLabelText("Tool invocation timestamp")).toBeVisible();
});
it("renders a single tool entry as one collapsed group and tolerates missing detail", () => {
@@ -886,10 +931,14 @@ describe("TaskChatTab", () => {
expect(screen.queryByText("Arguments")).not.toBeInTheDocument();
});
it("falls back to result entries when a tool completion has no preceding call", async () => {
const user = userEvent.setup();
it.each([
["result", "tool_result", "Tool result", "ok", "4m ago"],
["error", "tool_error", "Tool error", "stderr", "2m ago"],
] as const)("falls back to standalone %s entries when a tool completion has no preceding call", (_label, type, kickerText, detail, expectedTime) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z"));
mockLogs([
makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok" }),
makeEntry({ agent: "executor", type, text: "bash", detail, timestamp: type === "tool_result" ? "2026-06-17T14:56:00.000Z" : "2026-06-17T14:58:00.000Z" }),
]);
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
@@ -900,14 +949,16 @@ describe("TaskChatTab", () => {
expect(toolGroup).not.toHaveAttribute("open");
expect(within(summary as HTMLElement).getByText("1 tool call")).toBeVisible();
expect(within(summary as HTMLElement).getByText("bash")).toBeVisible();
expect(within(summary as HTMLElement).getByLabelText("Tool group timestamp")).toHaveTextContent(expectedTime);
expect(screen.queryByText("0 tool calls")).not.toBeInTheDocument();
await user.click(within(summary as HTMLElement).getByText("1 tool call"));
fireEvent.click(within(summary as HTMLElement).getByText("1 tool call"));
const standaloneEntry = screen.getByTestId("task-chat-entry-tool_result");
const standaloneKicker = screen.getByText("Tool result");
const standaloneEntry = screen.getByTestId(`task-chat-entry-${type}`);
const standaloneKicker = screen.getByText(kickerText);
expect(standaloneEntry).toHaveClass("task-chat-tool-entry");
expect(standaloneKicker).toHaveClass("task-chat-entry-kicker");
expect(within(standaloneEntry).getByLabelText("Tool entry timestamp")).toHaveTextContent(expectedTime);
});
it("renders thinking in an expanded-by-default collapsible block", async () => {
@@ -1623,6 +1674,7 @@ describe("TaskChatTab", () => {
const transcript = screen.getByTestId("task-chat-transcript");
expect(within(transcript).getByText("Optimistic timestamp")).toBeVisible();
expect(within(transcript).getByTestId("task-chat-user-time")).toHaveTextContent("just now");
expect(within(transcript).getByLabelText("User message block timestamp")).toHaveTextContent("just now");
});
it("renders a sent user message after pre-existing agent output under client-behind-server clock skew", () => {
@@ -1767,6 +1819,7 @@ describe("TaskChatTab", () => {
expect(within(transcript).getByText("invalid user timestamp")).toBeVisible();
expect(within(transcript).queryByTestId("task-chat-group-time")).not.toBeInTheDocument();
expect(within(transcript).queryByTestId("task-chat-user-time")).not.toBeInTheDocument();
expect(within(transcript).queryByTestId("task-chat-block-time")).not.toBeInTheDocument();
expect(transcript).not.toHaveTextContent(/NaN|Invalid Date/);
});
@@ -2584,6 +2637,9 @@ describe("TaskChatTab", () => {
const groupMetaRule = getCssRuleBlock(css, ".task-chat-group-meta");
const userHeaderRule = getCssRuleBlock(css, ".task-chat-user-header");
const timestampRule = getCssRuleBlock(css, ".task-chat-timestamp");
const blockTimestampRule = getCssRuleBlock(css, ".task-chat-entry-meta .task-chat-timestamp,");
const blockTimestampMetaRule = getCssRuleBlock(getCssAfter(css, ".task-chat-entry-meta .task-chat-timestamp {"), ".task-chat-entry-meta .task-chat-timestamp");
const summaryTimestampRule = getCssRuleBlock(getCssAfter(css, ".task-chat-entry-meta .task-chat-timestamp {\n margin-left: auto;\n}\n\n"), ".task-chat-entry-label-row .task-chat-timestamp,");
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
const mobileUserHeaderRule = getCssRuleBlock(mobileCss, ".task-chat-user-header");
const mobileTimestampRule = getCssRuleBlock(mobileCss, ".task-chat-timestamp");
@@ -2595,6 +2651,18 @@ describe("TaskChatTab", () => {
expect(timestampRule).toContain("font-size: calc(var(--space-md) - (var(--space-xs) / 2))");
expect(timestampRule).not.toContain("px");
expect(timestampRule).not.toContain("#");
expect(blockTimestampRule).toContain("display: inline-flex");
expect(blockTimestampRule).toContain("flex: 0 0 auto");
expect(blockTimestampRule).toContain("font-size: calc(var(--space-md) - (var(--space-xs) / 2))");
expect(blockTimestampRule).toContain("white-space: nowrap");
expect(blockTimestampMetaRule).toContain("margin-left: auto");
expect(summaryTimestampRule).toContain("max-inline-size: 40%");
expect(summaryTimestampRule).toContain("overflow: hidden");
expect(summaryTimestampRule).toContain("text-overflow: ellipsis");
expect(blockTimestampRule).not.toContain("px");
expect(blockTimestampRule).not.toContain("#");
expect(summaryTimestampRule).not.toContain("px");
expect(summaryTimestampRule).not.toContain("#");
expect(userHeaderRule).toContain("display: inline-flex");
expect(userHeaderRule).toContain("flex-wrap: wrap");
expect(mobileUserHeaderRule).toContain("justify-content: flex-end");