diff --git a/.changeset/fn-7241-task-chat-block-timestamps.md b/.changeset/fn-7241-task-chat-block-timestamps.md
new file mode 100644
index 0000000000..d77ab7ba53
--- /dev/null
+++ b/.changeset/fn-7241-task-chat-block-timestamps.md
@@ -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.
diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css
index f54bf40961..22bd092fe9 100644
--- a/packages/dashboard/app/components/TaskChatTab.css
+++ b/packages/dashboard/app/components/TaskChatTab.css
@@ -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;
diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx
index 874480c329..54331211b7 100644
--- a/packages/dashboard/app/components/TaskChatTab.tsx
+++ b/packages/dashboard/app/components/TaskChatTab.tsx
@@ -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 (
+
+ {relativeTime}
+
+ );
+}
+
+function TaskChatTimestampMeta({ timestamp, label }: { timestamp: string | undefined; label: string }) {
+ if (!getRelativeTimestamp(timestamp)) return null;
+ return (
+
+
+
+ );
+}
+
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}`}
>
+
{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}`}
>
- {formatEntryLabel(entry, t)}
+
+ {formatEntryLabel(entry, t)}
+
+
{entry.text}
{entry.detail ? {linkifyFilePaths(entry.detail)} : null}
@@ -393,8 +437,9 @@ function TaskChatToolInvocation({ row }: { row: Extract
-
- {completionLabel ? t("taskChat.toolCallTo", "Tool call → {{label}}", { label: completionLabel }) : t("taskChat.toolCall", "Tool call")}
+
+ {completionLabel ? t("taskChat.toolCallTo", "Tool call → {{label}}", { label: completionLabel }) : t("taskChat.toolCall", "Tool call")}
+
{row.call.text}
{row.call.detail ? (
@@ -440,6 +485,7 @@ function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) {
{formatErrorCount(errorCount, t)}
) : null}
+
{rows.map((row) => (
@@ -460,7 +506,10 @@ function TaskChatThinking({ entries }: { entries: AgentLogEntry[] }) {
return (
- {t("taskChat.thinking", "Thinking")}
+
+ {t("taskChat.thinking", "Thinking")}
+
+
+
{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 (
@@ -849,11 +898,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
{item.label}
{formatEntryCount(item.entries.length, t)}
- {relativeTime ? (
-
- {relativeTime}
-
- ) : null}
+
diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx
index 9834589757..e8e9033b2f 100644
--- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx
@@ -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();
+
+ 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(
+ ,
+ );
+
+ 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();
@@ -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");