From 9c9abc3bd7765bc9256c9c82fdabfc8eb9c13f2b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 00:15:52 -0700 Subject: [PATCH] FN-6344: combine adjacent chat text chunks Task detail chat now renders adjacent text log chunks as continuous bubbles. - Combine consecutive non-tool, non-thinking chat entries within each agent role group before rendering. - Preserve tool and thinking segment boundaries while simplifying segment keys for grouped text. - Add dashboard tests for single, consecutive, and cross-role text bubble behavior. - Document the text chunk grouping behavior in the dashboard guide. Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/TaskChatTab.tsx | 28 ++++++++----- .../app/components/__tests__/TaskChatTab.test.tsx | 48 ++++++++++++++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-6344 Fusion-Task-Lineage: e88f0383-7d91-4272-8c9b-532a382074d4 --- docs/dashboard-guide.md | 2 +- .../dashboard/app/components/TaskChatTab.tsx | 28 ++++++----- .../components/__tests__/TaskChatTab.test.tsx | 48 +++++++++++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6f5366390c..adc05f65e9 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -728,7 +728,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig ### Logs → Agent Log view -The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive tool/tool-result/tool-error rows inside a role group collapse into one expandable tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, the composer sends guidance to the running agent through the same steering path used by comments; when no active session is available, the composer is disabled with an explanatory hint. +The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, the composer sends guidance to the running agent through the same steering path used by comments; when no active session is available, the composer is disabled with an explanatory hint. The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions: diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index ba8d8a1592..a13a909226 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -32,7 +32,7 @@ interface AgentLogGroup { type TaskChatSegment = | { kind: "tool"; entries: AgentLogEntry[]; startIndex: number } | { kind: "thinking"; entries: AgentLogEntry[]; startIndex: number } - | { kind: "text"; entry: AgentLogEntry; index: number }; + | { kind: "text"; entries: AgentLogEntry[]; startIndex: number }; type TaskChatToolGroupRow = | { kind: "invocation"; call: AgentLogEntry; completion?: AgentLogEntry; callIndex: number; completionIndex?: number } @@ -175,22 +175,30 @@ function segmentGroupEntries(entries: AgentLogEntry[]): TaskChatSegment[] { continue; } - segments.push({ kind: "text", entry, index }); - index += 1; + const startIndex = index; + const textEntries: AgentLogEntry[] = []; + while (index < entries.length && !isToolLikeEntry(entries[index]) && entries[index].type !== "thinking") { + textEntries.push(entries[index]); + index += 1; + } + segments.push({ kind: "text", entries: textEntries, startIndex }); } return segments; } -function TaskChatTextEntry({ entry }: { entry: AgentLogEntry }) { +function TaskChatText({ entries }: { entries: AgentLogEntry[] }) { + const firstEntry = entries[0]; + if (!firstEntry) return null; + return (
- {entry.text} + {entries.map((entry) => entry.text).join("")}
@@ -327,7 +335,7 @@ function TaskChatSegmentView({ segment }: { segment: TaskChatSegment }) { if (segment.kind === "thinking") { return ; } - return ; + return ; } export function TaskChatTab({ task, projectId, active, addToast, sessionLive }: TaskChatTabProps) { @@ -507,9 +515,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive }:
{segments.map((segment) => { - const segmentKey = segment.kind === "text" - ? `text-${getEntryKey(segment.entry, segment.index)}` - : `${segment.kind}-${segment.startIndex}-${segment.entries.length}`; + const segmentKey = `${segment.kind}-${segment.startIndex}-${segment.entries.length}`; return ; })}
diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index b5e82ae35a..3412c09565 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -254,6 +254,52 @@ describe("TaskChatTab", () => { expect(screen.getByLabelText("Reviewer messages")).toBeTruthy(); }); + it("renders a single text entry as one text bubble", () => { + mockLogs([ + makeEntry({ agent: "executor", text: "single response" }), + ]); + + render(); + + const textBubbles = screen.getAllByTestId("task-chat-entry-text"); + expect(textBubbles).toHaveLength(1); + expect(within(textBubbles[0]).getByText("single response")).toBeVisible(); + }); + + it("combines consecutive text entries into one continuous text bubble", () => { + mockLogs([ + makeEntry({ agent: "executor", text: "first chunk " }), + makeEntry({ agent: "executor", text: "second chunk" }), + makeEntry({ agent: "executor", text: " third chunk" }), + ]); + + render(); + + const textBubbles = screen.getAllByTestId("task-chat-entry-text"); + expect(textBubbles).toHaveLength(1); + expect(textBubbles[0]).toHaveClass("task-chat-entry", "task-chat-entry--text"); + expect(textBubbles[0]).toHaveTextContent("first chunk second chunk third chunk"); + expect(within(textBubbles[0]).queryByRole("separator")).not.toBeInTheDocument(); + }); + + it("keeps text entries on different agent-role runs in separate bubbles", () => { + mockLogs([ + makeEntry({ agent: "executor", text: "executor first" }), + makeEntry({ agent: "executor", text: " executor second" }), + makeEntry({ agent: "reviewer", text: "reviewer first" }), + makeEntry({ agent: "reviewer", text: " reviewer second" }), + ]); + + render(); + + const textBubbles = screen.getAllByTestId("task-chat-entry-text"); + expect(textBubbles).toHaveLength(2); + expect(within(screen.getByLabelText("Executor messages")).getByTestId("task-chat-entry-text")) + .toHaveTextContent("executor first executor second"); + expect(within(screen.getByLabelText("Reviewer messages")).getByTestId("task-chat-entry-text")) + .toHaveTextContent("reviewer first reviewer second"); + }); + it("counts a tool call plus result as one collapsed invocation and shows the tool name", async () => { const user = userEvent.setup(); mockLogs([ @@ -423,6 +469,7 @@ describe("TaskChatTab", () => { expect(screen.getAllByText("1 tool call")).toHaveLength(2); expect(within(toolGroups[0]).getByLabelText("Tool names")).toHaveTextContent("first tool"); expect(within(toolGroups[1]).getByLabelText("Tool names")).toHaveTextContent("second tool"); + expect(screen.getAllByTestId("task-chat-entry-text")).toHaveLength(1); expect(screen.getByText("plain response")).toBeVisible(); expect(screen.getByText("thinking between tools")).toBeVisible(); }); @@ -446,6 +493,7 @@ describe("TaskChatTab", () => { expect(toolGroup).not.toHaveAttribute("open"); expect(screen.getByText("1 tool call")).toBeVisible(); expect(screen.getByText("streamed detail")).not.toBeVisible(); + expect(screen.getAllByTestId("task-chat-entry-text")).toHaveLength(2); expect(screen.getByText("second live chunk")).toBeVisible(); });