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:
7
.changeset/fn-7241-task-chat-block-timestamps.md
Normal file
7
.changeset/fn-7241-task-chat-block-timestamps.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user