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:
gsxdsm
2026-08-04 01:54:22 -07:00
parent 7b1c8db1d3
commit 3cc1d9373f
3 changed files with 49 additions and 70 deletions

View 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.

View File

@@ -1,5 +1,5 @@
import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from "@fusion/core"; 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 React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@@ -586,13 +586,10 @@ function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) {
} }
/* /*
FNXC:Chat-Thinking 2026-07-15-10:33: FNXC:Chat-Thinking 2026-08-04-08:15:
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) 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.
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.
*/ */
function TaskChatThinking({ entries, defaultOpen = false }: { entries: AgentLogEntry[]; defaultOpen?: boolean }) { function TaskChatThinking({ entries, defaultOpen = true }: { entries: AgentLogEntry[]; defaultOpen?: boolean }) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const combinedThinkingText = entries.map((entry) => entry.text).join(""); 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") { if (segment.kind === "tool") {
return <TaskChatToolGroup entries={segment.entries} />; return <TaskChatToolGroup entries={segment.entries} />;
} }
if (segment.kind === "thinking") { if (segment.kind === "thinking") {
return <TaskChatThinking entries={segment.entries} defaultOpen={defaultOpen} />; return <TaskChatThinking entries={segment.entries} />;
} }
return <TaskChatText 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 [optimisticMessages, setOptimisticMessages] = useState<UserChatMessage[]>([]);
const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
const isTranscriptAtBottomRef = useRef(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 transcriptRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef(0); const previousEntryCountRef = useRef(0);
const previousScrollHeightRef = 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. A genuinely new segment always has a different startIndex, so identity stays correct without the volatile suffix.
*/ */
const segmentKey = `${segment.kind}-${segment.startIndex}`; const segmentKey = `${segment.kind}-${segment.startIndex}`;
return <TaskChatSegmentView key={segmentKey} segment={segment} defaultOpen={thinkingDefaultOpen} />; return <TaskChatSegmentView key={segmentKey} segment={segment} />;
})} })}
</div> </div>
</section> </section>

View File

@@ -1149,7 +1149,26 @@ describe("TaskChatTab", () => {
expect(within(standaloneEntry).getByLabelText("Tool entry timestamp")).toHaveTextContent(expectedTime); 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(); const user = userEvent.setup();
mockLogs([ mockLogs([
makeEntry({ agent: "triage", type: "thinking", text: "I am considering options" }), 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()} />); render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={vi.fn()} />);
const thinking = screen.getByTestId("task-chat-thinking"); const thinking = screen.getByTestId("task-chat-thinking");
expect(thinking).not.toHaveAttribute("open"); expect(thinking).toHaveAttribute("open");
expect(within(thinking).getByText("Thinking")).toBeVisible(); 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); expect(within(thinking).getAllByTestId("task-chat-entry-thinking")).toHaveLength(1);
await user.click(within(thinking).getByText("Thinking")); 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(thinking).toHaveAttribute("open");
expect(screen.getByText("I am considering options")).toBeVisible(); 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", () => { it("renders consecutive thinking entries as one continuous section", () => {
@@ -1220,6 +1202,7 @@ describe("TaskChatTab", () => {
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />); render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
const thinking = screen.getByTestId("task-chat-thinking"); const thinking = screen.getByTestId("task-chat-thinking");
expect(thinking).toHaveAttribute("open");
const summary = thinking.querySelector("summary"); const summary = thinking.querySelector("summary");
expect(summary).toBeTruthy(); expect(summary).toBeTruthy();
expect(within(summary as HTMLElement).getByText("Thinking")).toBeVisible(); expect(within(summary as HTMLElement).getByText("Thinking")).toBeVisible();
@@ -1234,7 +1217,7 @@ describe("TaskChatTab", () => {
FNXC:TaskChatSegmentKey 2026-07-22-12:00: 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. 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(); const user = userEvent.setup();
mockLogs([ mockLogs([
makeEntry({ agent: "executor", type: "thinking", text: "First streamed thought" }), 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 { rerender } = render(<TaskChatTab task={makeTask({ column: "todo" })} active addToast={vi.fn()} />);
const thinking = screen.getByTestId("task-chat-thinking"); const thinking = screen.getByTestId("task-chat-thinking");
expect(thinking).not.toHaveAttribute("open");
await user.click(within(thinking).getByText("Thinking"));
expect(thinking).toHaveAttribute("open"); expect(thinking).toHaveAttribute("open");
await user.click(within(thinking).getByText("Thinking"));
expect(thinking).not.toHaveAttribute("open");
mockLogs([ mockLogs([
makeEntry({ agent: "executor", type: "thinking", text: "First streamed thought" }), 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()} />); rerender(<TaskChatTab task={makeTask({ column: "todo" })} active addToast={vi.fn()} />);
expect(screen.getByTestId("task-chat-thinking")).toHaveAttribute("open"); expect(screen.getByTestId("task-chat-thinking")).not.toHaveAttribute("open");
expect(screen.getByText(/Second streamed thought/)).toBeVisible(); expect(screen.getByText(/Second streamed thought/)).not.toBeVisible();
}); });
it("gives a genuinely new segment a fresh instance with defaultOpen applied", async () => { 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(within(toolGroups[1]).getByLabelText("Tool names")).toHaveTextContent("second tool");
expect(screen.getAllByTestId("task-chat-entry-text")).toHaveLength(1); expect(screen.getAllByTestId("task-chat-entry-text")).toHaveLength(1);
expect(screen.getByText("plain response")).toBeVisible(); 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", () => { 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 source = readFileSync(resolve(__dirname, "../TaskDetailModal.tsx"), "utf8");
const taskChatMounts = source.match(/<TaskChatTab\b/g) ?? []; 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(taskChatMounts).toHaveLength(1);
expect(source).toContain("const isActivityExpanded = activityExpanded && activeTab === \"chat\" && !isEditing"); expect(source).toContain("const isActivityExpanded = activityExpanded && activeTab === \"chat\" && !isEditing");
expect(source).toContain("task-detail-content--chat-expanded"); expect(source).toContain("task-detail-content--chat-expanded");