From 5fe1c8e379395cc47f8bdf98505b56407453603a Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Wed, 19 Aug 2026 03:01:40 +0000 Subject: [PATCH] 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 --- .../app/components/StandardChatSurface.tsx | 33 +++++++++-- .../dashboard/app/components/TaskChatTab.tsx | 19 +++++-- .../ChatView.core-interactions.test.tsx | 23 ++++++-- .../__tests__/ChatView.core.test.tsx | 55 ++++++++++++++++++- .../components/__tests__/TaskChatTab.test.tsx | 36 +++++++----- .../__tests__/TaskPlannerChatTab.test.tsx | 49 ++++++++++++++++- 6 files changed, 186 insertions(+), 29 deletions(-) diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx index 9051127abb..e50c2dd816 100644 --- a/packages/dashboard/app/components/StandardChatSurface.tsx +++ b/packages/dashboard/app/components/StandardChatSurface.tsx @@ -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) => { + if (isInteractiveDisclosureTarget(event.target)) return; + const details = event.currentTarget.closest("details"); + if (details?.open) details.open = false; + }, []); + + return ( +
+ {t("chat.thinking", "Thinking")} +
{linkifyFilePaths(thinking)}
+
+ ); +} + 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
{summary}
; } return ( -
+
{summary} 0 ? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})` : null; return (
-
+
{t("chat.toolCallsCount", "{{count}} tool calls", { count: nonQuestionToolCalls.length })} @@ -731,7 +756,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ )} {hasAssistantFooterRow && (
- {message.thinkingOutput &&
{t("chat.thinking", "Thinking")}
{linkifyFilePaths(message.thinkingOutput)}
} + {message.thinkingOutput && } {(copyAction || onScrollToTop) && (
{copyAction} @@ -760,7 +785,7 @@ export function StandardStreamingMessage({ streamingText, streamingThinking = "" {streamingText ? renderStandardAssistantContent(streamingText, forcePlain) :
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.workingStatus", "Working…")}
} {copyAction} {renderStandardToolCalls(streamingToolCalls, t, { isAwaitingAnswer: true, onQuestionSubmit, toolCallRenderer })} - {streamingThinking &&
{t("chat.thinking", "Thinking")}
{linkifyFilePaths(streamingThinking)}
} + {streamingThinking && }
); diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 606e1b6fbd..40d17d7c74 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -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) => { + if (isInteractiveDisclosureTarget(event.target)) return; + setOpen(false); + }, []); return (
{t("taskChat.thinking", "Thinking")}
-
+
{ 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(); + 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(); diff --git a/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx index 3a673fe36d..968b5fc180 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core.test.tsx @@ -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(); + 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(); + }); + + 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 () => { diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 3499158fe2..60b1854bcf 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -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(); 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(); 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(); 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(); 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(); 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", () => { diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index fda5bef9a1..e3300a739d 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -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({