FN-025: preserve user-controlled chat disclosures

Keep thinking and tool disclosure state under operator control across chat surfaces.\n\n- Start new thinking and tool groups collapsed instead of auto-opening during streaming.\n- Preserve disclosure state across streaming updates and reattached planner sessions.\n- Allow non-interactive disclosure content to dismiss expanded blocks while preserving interactive descendants.\n- Add desktop and mobile regression coverage for chat, task activity, and planner views.\n\nFiles changed:\n .../app/components/StandardChatSurface.tsx         | 33 +++++++++++--\n packages/dashboard/app/components/TaskChatTab.tsx  | 19 ++++++--\n .../__tests__/ChatView.core-interactions.test.tsx  | 23 +++++++--\n .../components/__tests__/ChatView.core.test.tsx    | 55 +++++++++++++++++++++-\n .../app/components/__tests__/TaskChatTab.test.tsx  | 36 +++++++++-----\n .../__tests__/TaskPlannerChatTab.test.tsx          | 49 ++++++++++++++++++-\n 6 files changed, 186 insertions(+), 29 deletions(-)

Fusion-Task-Id: FN-025

Fusion-Task-Lineage: 294eadc8-d8fe-48da-8a1e-96aed559e54d

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 03:01:40 +00:00
parent b88bb1d532
commit 5fe1c8e379
6 changed files with 186 additions and 29 deletions

View File

@@ -154,6 +154,31 @@ function formatToolResultSummary(result: unknown): string | null {
return formatToolPreview(result, 200);
}
function isInteractiveDisclosureTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("a,button,input,textarea,select,summary,[role=\"button\"],[contenteditable=\"true\"]"));
}
/*
FNXC:ChatDisclosure 2026-08-19-02:42:
Streaming status is presentation-only: disclosure state belongs to the user and must not be taken over by a running tool or thinking delta. Keep the native summary interaction while allowing a click on non-interactive thinking content to dismiss an expanded block.
*/
function StandardThinkingDisclosure({ thinking }: { thinking: string }) {
const { t } = useTranslation("app");
const handleBodyClick = useCallback((event: React.MouseEvent<HTMLPreElement>) => {
if (isInteractiveDisclosureTarget(event.target)) return;
const details = event.currentTarget.closest("details");
if (details?.open) details.open = false;
}, []);
return (
<details className="chat-message-thinking">
<summary>{t("chat.thinking", "Thinking")}</summary>
<pre className="chat-message-thinking-content" onClick={handleBodyClick}>{linkifyFilePaths(thinking)}</pre>
</details>
);
}
function buildFailureReferenceHref(reference: FailureInfo["reference"]): string | null {
if (!reference) return null;
if (reference.kind === "mailbox" || reference.kind === "mailbox-message") {
@@ -242,7 +267,7 @@ export function renderStandardToolCalls(
return <div key={`${toolCall.toolName}-${index}`} className={className}><div className="chat-tool-call-summary">{summary}</div></div>;
}
return (
<details key={`${toolCall.toolName}-${index}`} className={className} open={isRunning}>
<details key={`${toolCall.toolName}-${index}`} className={className}>
<summary>{summary}</summary>
<ToolCallDetails
className="chat-tool-call-content"
@@ -284,7 +309,7 @@ export function renderStandardToolCalls(
const statusSummary = hasRunning ? `(${runningCount} ${t("chat.toolCallStatusRunning", "running")})` : errorCount > 0 ? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})` : null;
return (
<div className="chat-tool-calls" data-testid="chat-tool-calls">
<details className="chat-tool-calls-group" data-testid="chat-tool-calls-group" open={hasRunning}>
<details className="chat-tool-calls-group" data-testid="chat-tool-calls-group">
<summary className="chat-tool-calls-group-summary">
<span className="chat-tool-calls-header-icon" aria-hidden="true">•</span>
<span className="chat-tool-calls-count">{t("chat.toolCallsCount", "{{count}} tool calls", { count: nonQuestionToolCalls.length })}</span>
@@ -731,7 +756,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
)}
{hasAssistantFooterRow && (
<div className={`chat-message-thinking-row${hasVisibleAssistantFooterContent ? "" : " chat-message-thinking-row--collapsed"}`}>
{message.thinkingOutput && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre></details>}
{message.thinkingOutput && <StandardThinkingDisclosure thinking={message.thinkingOutput} />}
{(copyAction || onScrollToTop) && (
<div className="chat-message-actions">
{copyAction}
@@ -760,7 +785,7 @@ export function StandardStreamingMessage({ streamingText, streamingThinking = ""
{streamingText ? renderStandardAssistantContent(streamingText, forcePlain) : <div className="chat-message-content chat-message-content--waiting">{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.workingStatus", "Working…")}</div>}
{copyAction}
{renderStandardToolCalls(streamingToolCalls, t, { isAwaitingAnswer: true, onQuestionSubmit, toolCallRenderer })}
{streamingThinking && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre></details>}
{streamingThinking && <StandardThinkingDisclosure thinking={streamingThinking} />}
<div className="chat-typing-indicator"><span /><span /><span /></div>
</div>
);

View File

@@ -589,13 +589,22 @@ function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) {
}
/*
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.
FNXC:TaskChatDisclosure 2026-08-19-02:47:
Task Activity thinking is user-owned disclosure: every new segment starts collapsed, streaming appends preserve its controlled state, and a click on non-interactive body content closes an expanded segment without requiring a return to its summary. Interactive descendants remain usable.
*/
function TaskChatThinking({ entries, defaultOpen = true }: { entries: AgentLogEntry[]; defaultOpen?: boolean }) {
function isInteractiveDisclosureTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return Boolean(target.closest("a,button,input,textarea,select,summary,[role=\"button\"],[contenteditable=\"true\"]"));
}
function TaskChatThinking({ entries }: { entries: AgentLogEntry[] }) {
const { t } = useTranslation("app");
const [open, setOpen] = useState(defaultOpen);
const [open, setOpen] = useState(false);
const combinedThinkingText = entries.map((entry) => entry.text).join("");
const handleBodyClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (isInteractiveDisclosureTarget(event.target)) return;
setOpen(false);
}, []);
return (
<details
@@ -608,7 +617,7 @@ function TaskChatThinking({ entries, defaultOpen = true }: { entries: AgentLogEn
<span>{t("taskChat.thinking", "Thinking")}</span>
<TaskChatTimestamp timestamp={getLatestEntryTimestamp(entries)} label="Thinking block timestamp" />
</summary>
<div className="task-chat-thinking-body">
<div className="task-chat-thinking-body" onClick={handleBodyClick}>
<div
className="markdown-body task-chat-markdown task-chat-thinking-markdown"
data-testid="task-chat-entry-thinking"

View File

@@ -1009,27 +1009,39 @@ describe("ChatView core interactions", () => {
expect(streamingMessage?.textContent).toContain("Typing");
});
it("keeps persisted thinking blocks collapsed until expanded", async () => {
it.each([
["desktop", "desktop"],
["mobile", "mobile"],
] as const)("keeps persisted thinking user-owned on %s", async (_viewport, viewport) => {
const viewportSpy = mockViewportMode(viewport);
const user = userEvent.setup();
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
sessions: [activeSessionFixture],
filteredSessions: [activeSessionFixture],
activeSession: activeSessionFixture,
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Here's my response", thinkingOutput: "I need to think about this...", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
if (viewport === "mobile") {
await user.click(screen.getByTestId("chat-session-session-001"));
}
const message = screen.getByTestId("chat-message-msg-001");
const details = message.querySelector("details") as HTMLDetailsElement;
const details = message.querySelector("details.chat-message-thinking") as HTMLDetailsElement;
expect(details).toBeInTheDocument();
expect(details).not.toHaveAttribute("open");
expect(within(message).getByText("I need to think about this...")).not.toBeVisible();
await user.click(within(details).getByText("Thinking"));
expect(details).toHaveAttribute("open");
expect(within(message).getByText("I need to think about this...")).toBeVisible();
await user.click(within(details).getByText("I need to think about this..."));
expect(details).not.toHaveAttribute("open");
viewportSpy.mockRestore();
});
/*
@@ -1203,6 +1215,9 @@ describe("ChatView core interactions", () => {
expect(thinkingDetails).toHaveAttribute("open");
expect(within(thinkingDetails).getByText("analyzing the request...")).toBeVisible();
await user.click(within(thinkingDetails).getByText("analyzing the request..."));
expect(thinkingDetails).not.toHaveAttribute("open");
// Typing indicator dots should be rendered
const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator");
expect(typingIndicator).toBeInTheDocument();

View File

@@ -623,6 +623,56 @@ describe("ChatView", () => {
expect(preview).toHaveTextContent("path=foo.ts");
});
it.each([
["desktop", "desktop"],
["mobile", "mobile"],
] as const)("keeps streaming tool disclosure state user-owned on %s", async (_viewport, viewport) => {
const viewportSpy = mockViewportMode(viewport);
const runningToolCalls = [
{ toolName: "read", args: { path: "foo.ts" }, isError: false, status: "running" as const },
{ toolName: "read", args: { path: "bar.ts" }, isError: false, status: "running" as const },
];
const completedToolCalls = [
{ toolName: "read", args: { path: "foo.ts" }, result: "first result", isError: false, status: "completed" as const },
{ toolName: "read", args: { path: "bar.ts" }, result: "second result", isError: false, status: "completed" as const },
];
mockUseChat
.mockReturnValueOnce({
...defaultChatState,
activeSession: activeSessionFixture,
messages: [],
isStreaming: true,
streamingText: "Working...",
streamingToolCalls: runningToolCalls,
})
.mockReturnValue({
...defaultChatState,
activeSession: activeSessionFixture,
messages: [],
isStreaming: true,
streamingText: "Working...",
streamingToolCalls: completedToolCalls,
});
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
expect(group).not.toHaveAttribute("open");
await userEvent.click(group.querySelector("summary") as HTMLElement);
expect(group).toHaveAttribute("open");
await act(async () => {
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
});
const updatedGroup = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
expect(updatedGroup).toHaveAttribute("open");
expect(within(updatedGroup).queryByText("(1 running)")).not.toBeInTheDocument();
expect(updatedGroup).toHaveTextContent("first result");
expect(updatedGroup).toHaveTextContent("second result");
viewportSpy.mockRestore();
});
it("collapses multiple tool calls into single summary line", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
@@ -663,7 +713,7 @@ describe("ChatView", () => {
expect(summary.querySelector(".chat-tool-calls-names")).toHaveTextContent("read, grep");
});
it("auto-opens grouped tool calls when any tool call is running", async () => {
it("keeps grouped tool calls collapsed while any tool call is running", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
@@ -694,7 +744,8 @@ describe("ChatView", () => {
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
expect(group).toBeInTheDocument();
expect(group.open).toBe(true);
expect(group.open).toBe(false);
expect(within(group).getByText("(1 running)")).toBeVisible();
});
it("shows status counts in group summary", async () => {

View File

@@ -1157,7 +1157,7 @@ describe("TaskChatTab", () => {
["planning", "triage"],
["terminal", "done"],
["archived", "archived"],
] as const)("defaults thinking blocks open for %s tasks", (_state, column) => {
] as const)("defaults thinking blocks collapsed for %s tasks", (_state, column) => {
mockLogs([
makeEntry({ agent: "executor", type: "thinking", text: "Immediately readable reasoning" }),
]);
@@ -1165,31 +1165,37 @@ describe("TaskChatTab", () => {
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();
expect(thinking).not.toHaveAttribute("open");
expect(screen.getByText("Immediately readable reasoning")).not.toBeVisible();
});
it("lets users collapse and reopen initially expanded thinking blocks", async () => {
it.each([
["desktop", false],
["mobile", true],
] as const)("lets users dismiss and reopen thinking blocks from the body on %s", async (_viewport, matchesMobile) => {
const user = userEvent.setup();
mockLogs([
makeEntry({ agent: "triage", type: "thinking", text: "I am considering options" }),
]);
mockMatchMedia(matchesMobile);
render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={vi.fn()} />);
const thinking = screen.getByTestId("task-chat-thinking");
expect(thinking).toHaveAttribute("open");
expect(thinking).not.toHaveAttribute("open");
expect(within(thinking).getByText("Thinking")).toBeVisible();
expect(screen.getByText("I am considering options")).toBeVisible();
expect(screen.getByText("I am considering options")).not.toBeVisible();
expect(within(thinking).getAllByTestId("task-chat-entry-thinking")).toHaveLength(1);
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("I am considering options"));
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();
});
@@ -1203,7 +1209,7 @@ describe("TaskChatTab", () => {
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
const thinking = screen.getByTestId("task-chat-thinking");
expect(thinking).toHaveAttribute("open");
expect(thinking).not.toHaveAttribute("open");
const summary = thinking.querySelector("summary");
expect(summary).toBeTruthy();
expect(within(summary as HTMLElement).getByText("Thinking")).toBeVisible();
@@ -1227,8 +1233,10 @@ describe("TaskChatTab", () => {
const { rerender } = render(<TaskChatTab task={makeTask({ column: "todo" })} active addToast={vi.fn()} />);
const thinking = screen.getByTestId("task-chat-thinking");
expect(thinking).toHaveAttribute("open");
expect(thinking).not.toHaveAttribute("open");
await user.click(within(thinking).getByText("Thinking"));
expect(thinking).toHaveAttribute("open");
await user.click(within(thinking).getByText("First streamed thought"));
expect(thinking).not.toHaveAttribute("open");
mockLogs([
@@ -1241,7 +1249,7 @@ describe("TaskChatTab", () => {
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 thinking segment a fresh collapsed instance", async () => {
const user = userEvent.setup();
mockLogs([
makeEntry({ agent: "executor", type: "thinking", text: "Original reasoning" }),
@@ -1250,8 +1258,10 @@ describe("TaskChatTab", () => {
const { rerender } = render(<TaskChatTab task={makeTask({ column: "in-progress" })} active addToast={vi.fn()} />);
const firstThinking = screen.getByTestId("task-chat-thinking");
expect(firstThinking).toHaveAttribute("open");
expect(firstThinking).not.toHaveAttribute("open");
await user.click(within(firstThinking).getByText("Thinking"));
expect(firstThinking).toHaveAttribute("open");
await user.click(within(firstThinking).getByText("Original reasoning"));
expect(firstThinking).not.toHaveAttribute("open");
mockLogs([
@@ -1264,7 +1274,7 @@ describe("TaskChatTab", () => {
const thinkingBlocks = screen.getAllByTestId("task-chat-thinking");
expect(thinkingBlocks).toHaveLength(2);
expect(thinkingBlocks[0]).not.toHaveAttribute("open");
expect(thinkingBlocks[1]).toHaveAttribute("open");
expect(thinkingBlocks[1]).not.toHaveAttribute("open");
});
it("creates distinct tool segments when text or thinking entries are interleaved", () => {
@@ -1286,7 +1296,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")).toBeVisible();
expect(screen.getByText("thinking between tools")).not.toBeVisible();
});
it("appends newly streamed entries from the hook without auto-opening tool groups", () => {

View File

@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
import { ChatMessageLayoutProvider } from "../../context/ChatMessageLayoutContext";
@@ -1250,6 +1250,53 @@ describe("TaskPlannerChatTab", () => {
expect(details).toHaveTextContent("PLANNER_RESULT_SUFFIX");
});
it.each([
["desktop", false],
["mobile", true],
] as const)("keeps reattached planner tools and thinking collapsed on %s", async (_viewport, matchesMobile) => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: matchesMobile && query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
const inFlightGeneration = {
status: "generating",
streamingText: "Planner is working",
streamingThinking: "Planner is checking the task",
toolCalls: [
{ toolName: "read", args: { path: "one.ts" }, status: "running", isError: false },
{ toolName: "read", args: { path: "two.ts" }, status: "running", isError: false },
],
replayFromEventId: 3,
updatedAt: "2026-07-01T14:00:00.000Z",
};
const plannerSession = makePlannerSession({ isGenerating: true, inFlightGeneration });
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: plannerSession });
mockFetchChatSession.mockResolvedValue({ session: plannerSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
const user = userEvent.setup();
renderPlannerChat();
const group = await screen.findByTestId("chat-tool-calls-group") as HTMLDetailsElement;
expect(group).not.toHaveAttribute("open");
const thinking = await screen.findByTestId("chat-message-__streaming__").then((message) => message.querySelector("details.chat-message-thinking") as HTMLDetailsElement);
expect(thinking).not.toHaveAttribute("open");
await user.click(within(thinking).getByText("Thinking"));
expect(thinking).toHaveAttribute("open");
await user.click(within(thinking).getByText("Planner is checking the task"));
expect(thinking).not.toHaveAttribute("open");
});
it("renders mixed persisted planner question tool calls with the shared answer UI outside collapsed details", async () => {
const user = userEvent.setup();
mockFetchChatMessages.mockResolvedValue({