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");