FN-8780: expand Activity thinking blocks by default
Make Task Detail Activity reasoning immediately readable in every workflow column. - Default newly mounted thinking segments to expanded state - Preserve user collapse choices while streamed reasoning updates - Add coverage for default, toggle, and streaming behavior Files changed: .changeset/fn-8780-activity-thinking-open.md | 7 ++ packages/dashboard/app/components/TaskChatTab.tsx | 25 ++----- .../app/components/__tests__/TaskChatTab.test.tsx | 79 +++++++++------------- 3 files changed, 45 insertions(+), 66 deletions(-) Fusion-Task-Id: FN-8780 Fusion-Task-Lineage: 51a0be0d-0bc8-4e1f-ac1d-b30583d7b202 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8780-activity-thinking-open.md
Normal file
7
.changeset/fn-8780-activity-thinking-open.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Expand Task Detail Activity thinking blocks by default.
|
||||
category: fix
|
||||
dev: Preserves per-block collapse state while streamed reasoning updates arrive.
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from "@fusion/core";
|
||||
import { isCompleteColumnRole, isReviewColumnRole, isWipColumnRole } from "../utils/columnRoles";
|
||||
import { isCompleteColumnRole, isWipColumnRole } from "../utils/columnRoles";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -586,13 +586,10 @@ function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) {
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Chat-Thinking 2026-07-15-10:33:
|
||||
Chat thinking (reasoning) blocks render collapsed by default so the response is scannable without manually closing each block; the summary remains an expand-on-click affordance. (FN-7974)
|
||||
|
||||
FNXC:Chat-Thinking 2026-07-16-18:05:
|
||||
FN-8171 keeps the scannable collapsed default for idle task columns, but opens Live Activity thinking for in-progress and in-review tasks so operators can follow active or awaiting-review reasoning at a glance. The expanded block remains user-collapsible.
|
||||
FNXC:Chat-Thinking 2026-08-04-08:15:
|
||||
FN-8780 requires every newly mounted Task Detail Activity thinking segment to start expanded, regardless of workflow column, so operators can read reasoning immediately. State remains controlled after mount: the summary still lets operators collapse or reopen a segment, and stable segment identity preserves that choice during streaming.
|
||||
*/
|
||||
function TaskChatThinking({ entries, defaultOpen = false }: { entries: AgentLogEntry[]; defaultOpen?: boolean }) {
|
||||
function TaskChatThinking({ entries, defaultOpen = true }: { entries: AgentLogEntry[]; defaultOpen?: boolean }) {
|
||||
const { t } = useTranslation("app");
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const combinedThinkingText = entries.map((entry) => entry.text).join("");
|
||||
@@ -622,12 +619,12 @@ function TaskChatThinking({ entries, defaultOpen = false }: { entries: AgentLogE
|
||||
);
|
||||
}
|
||||
|
||||
function TaskChatSegmentView({ segment, defaultOpen }: { segment: TaskChatSegment; defaultOpen?: boolean }) {
|
||||
function TaskChatSegmentView({ segment }: { segment: TaskChatSegment }) {
|
||||
if (segment.kind === "tool") {
|
||||
return <TaskChatToolGroup entries={segment.entries} />;
|
||||
}
|
||||
if (segment.kind === "thinking") {
|
||||
return <TaskChatThinking entries={segment.entries} defaultOpen={defaultOpen} />;
|
||||
return <TaskChatThinking entries={segment.entries} />;
|
||||
}
|
||||
return <TaskChatText entries={segment.entries} />;
|
||||
}
|
||||
@@ -672,14 +669,6 @@ export function TaskChatTab({ task, columnFlags, projectId, active, addToast, on
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
|
||||
const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
|
||||
const isTranscriptAtBottomRef = useRef(true);
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-30-02:10 (batch-dashboard-app):
|
||||
WIP and REVIEW roles, resolved. This decides whether the thinking transcript is expanded by
|
||||
default — open while work is live, collapsed once it is not. Keyed on the literals, a renamed
|
||||
board collapsed it for every card, so an operator watching an active run had to expand it by hand
|
||||
on every task, every time.
|
||||
*/
|
||||
const thinkingDefaultOpen = isWipColumnRole(columnFlags, task.column) || isReviewColumnRole(columnFlags, task.column);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const previousEntryCountRef = useRef(0);
|
||||
const previousScrollHeightRef = useRef(0);
|
||||
@@ -1103,7 +1092,7 @@ export function TaskChatTab({ task, columnFlags, projectId, active, addToast, on
|
||||
A genuinely new segment always has a different startIndex, so identity stays correct without the volatile suffix.
|
||||
*/
|
||||
const segmentKey = `${segment.kind}-${segment.startIndex}`;
|
||||
return <TaskChatSegmentView key={segmentKey} segment={segment} defaultOpen={thinkingDefaultOpen} />;
|
||||
return <TaskChatSegmentView key={segmentKey} segment={segment} />;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1149,7 +1149,26 @@ describe("TaskChatTab", () => {
|
||||
expect(within(standaloneEntry).getByLabelText("Tool entry timestamp")).toHaveTextContent(expectedTime);
|
||||
});
|
||||
|
||||
it("renders thinking in a collapsed-by-default expandable block for inactive tasks", async () => {
|
||||
it.each([
|
||||
["WIP", "in-progress"],
|
||||
["review", "in-review"],
|
||||
["idle", "todo"],
|
||||
["planning", "triage"],
|
||||
["terminal", "done"],
|
||||
["archived", "archived"],
|
||||
] as const)("defaults thinking blocks open for %s tasks", (_state, column) => {
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", type: "thinking", text: "Immediately readable reasoning" }),
|
||||
]);
|
||||
|
||||
render(<TaskChatTab task={makeTask({ column })} active addToast={vi.fn()} />);
|
||||
|
||||
const thinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
expect(screen.getByText("Immediately readable reasoning")).toBeVisible();
|
||||
});
|
||||
|
||||
it("lets users collapse and reopen initially expanded thinking blocks", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockLogs([
|
||||
makeEntry({ agent: "triage", type: "thinking", text: "I am considering options" }),
|
||||
@@ -1158,57 +1177,20 @@ describe("TaskChatTab", () => {
|
||||
render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={vi.fn()} />);
|
||||
|
||||
const thinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
expect(within(thinking).getByText("Thinking")).toBeVisible();
|
||||
expect(screen.getByText("I am considering options")).not.toBeVisible();
|
||||
expect(screen.getByText("I am considering options")).toBeVisible();
|
||||
expect(within(thinking).getAllByTestId("task-chat-entry-thinking")).toHaveLength(1);
|
||||
|
||||
await user.click(within(thinking).getByText("Thinking"));
|
||||
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
expect(screen.getByText("I am considering options")).not.toBeVisible();
|
||||
|
||||
await user.click(within(thinking).getByText("Thinking"));
|
||||
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
expect(screen.getByText("I am considering options")).toBeVisible();
|
||||
|
||||
await user.click(within(thinking).getByText("Thinking"));
|
||||
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
expect(screen.getByText("I am considering options")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it.each(["in-progress", "in-review"] as const)("defaults thinking blocks open for %s tasks", (column) => {
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", type: "thinking", text: "Live reasoning" }),
|
||||
]);
|
||||
|
||||
render(<TaskChatTab task={makeTask({ column })} active addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("task-chat-thinking")).toHaveAttribute("open");
|
||||
});
|
||||
|
||||
it.each(["todo", "done", "triage", "archived"] as const)("keeps thinking blocks collapsed for %s tasks", (column) => {
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", type: "thinking", text: "Historical reasoning" }),
|
||||
]);
|
||||
|
||||
render(<TaskChatTab task={makeTask({ column })} active addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("task-chat-thinking")).not.toHaveAttribute("open");
|
||||
});
|
||||
|
||||
it("lets users collapse auto-expanded thinking blocks", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", type: "thinking", text: "Active reasoning" }),
|
||||
]);
|
||||
|
||||
render(<TaskChatTab task={makeTask({ column: "in-progress" })} active addToast={vi.fn()} />);
|
||||
|
||||
const thinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
|
||||
await user.click(within(thinking).getByText("Thinking"));
|
||||
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
expect(screen.getByText("Active reasoning")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("renders consecutive thinking entries as one continuous section", () => {
|
||||
@@ -1220,6 +1202,7 @@ describe("TaskChatTab", () => {
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
|
||||
const thinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
const summary = thinking.querySelector("summary");
|
||||
expect(summary).toBeTruthy();
|
||||
expect(within(summary as HTMLElement).getByText("Thinking")).toBeVisible();
|
||||
@@ -1234,7 +1217,7 @@ describe("TaskChatTab", () => {
|
||||
FNXC:TaskChatSegmentKey 2026-07-22-12:00:
|
||||
Segment keys must be kind + startIndex only. Embedding entries.length remounted the actively streaming segment on every appended entry, collapsing an expanded thinking block mid-stream.
|
||||
*/
|
||||
it("keeps an expanded thinking block expanded while entries stream into the same segment", async () => {
|
||||
it("keeps a user-collapsed thinking block collapsed while entries stream into the same segment", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", type: "thinking", text: "First streamed thought" }),
|
||||
@@ -1243,9 +1226,9 @@ describe("TaskChatTab", () => {
|
||||
const { rerender } = render(<TaskChatTab task={makeTask({ column: "todo" })} active addToast={vi.fn()} />);
|
||||
|
||||
const thinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
await user.click(within(thinking).getByText("Thinking"));
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
await user.click(within(thinking).getByText("Thinking"));
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", type: "thinking", text: "First streamed thought" }),
|
||||
@@ -1253,8 +1236,8 @@ describe("TaskChatTab", () => {
|
||||
]);
|
||||
rerender(<TaskChatTab task={makeTask({ column: "todo" })} active addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("task-chat-thinking")).toHaveAttribute("open");
|
||||
expect(screen.getByText(/Second streamed thought/)).toBeVisible();
|
||||
expect(screen.getByTestId("task-chat-thinking")).not.toHaveAttribute("open");
|
||||
expect(screen.getByText(/Second streamed thought/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("gives a genuinely new segment a fresh instance with defaultOpen applied", async () => {
|
||||
@@ -1302,7 +1285,7 @@ describe("TaskChatTab", () => {
|
||||
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")).not.toBeVisible();
|
||||
expect(screen.getByText("thinking between tools")).toBeVisible();
|
||||
});
|
||||
|
||||
it("appends newly streamed entries from the hook without auto-opening tool groups", () => {
|
||||
@@ -2962,7 +2945,7 @@ describe("TaskChatTab", () => {
|
||||
const source = readFileSync(resolve(__dirname, "../TaskDetailModal.tsx"), "utf8");
|
||||
const taskChatMounts = source.match(/<TaskChatTab\b/g) ?? [];
|
||||
|
||||
expect(source).toContain('import { TaskChatTab } from "../TaskChatTab"');
|
||||
expect(source).toContain('import { TaskChatTab } from "./TaskChatTab"');
|
||||
expect(taskChatMounts).toHaveLength(1);
|
||||
expect(source).toContain("const isActivityExpanded = activityExpanded && activeTab === \"chat\" && !isEditing");
|
||||
expect(source).toContain("task-detail-content--chat-expanded");
|
||||
|
||||
Reference in New Issue
Block a user